Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 73 additions & 7 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: all test help tests tests-coverage check-coverage build build-api build-worker build-backfill-embeddings run run-worker run-backfill-embeddings init-db clean docker-up docker-down docker-clean deps install-tools fmt lint lint-new lint-openapi dev-setup test-all test-unit schemathesis install-hooks migrate-status migrate-validate river-migrate
.PHONY: all test help tests tests-coverage check-coverage build build-api build-worker build-backfill-embeddings run run-api run-worker run-backfill-embeddings init-db clean docker-up docker-down docker-clean deps install-tools fmt lint lint-new lint-openapi dev-setup test-all test-unit schemathesis install-hooks migrate-status migrate-validate river-migrate

# Aliases for checkmake/lint expectations
all: build
Expand All @@ -13,8 +13,9 @@ help:
@echo " make build-api - Build hub-api only (bin/hub-api)"
@echo " make build-worker - Build hub-worker only (bin/hub-worker)"
@echo " make build-backfill-embeddings - Build the backfill-embeddings command"
@echo " make run - Run the API server (hub-api)"
@echo " make run-worker - Run the worker (hub-worker)"
@echo " make run - Run River migrations, then hub-api and hub-worker"
@echo " make run-api - Run the API server only (hub-api)"
@echo " make run-worker - Run the worker only (hub-worker)"
@echo " make run-backfill-embeddings - Run the backfill-embeddings command (enqueues embedding jobs; loads .env)"
@echo " make test-unit - Run unit tests (fast, no database)"
@echo " make tests - Run integration tests"
Expand Down Expand Up @@ -109,13 +110,78 @@ run-backfill-embeddings:
@if [ ! -f .env ]; then echo "Error: .env file required. Copy .env.example to .env and configure."; exit 1; fi && \
(set -a && . ./.env && set +a && go run ./cmd/backfill-embeddings)

# Run the API server (hub-api).
define RUN_LOCAL_APP
set -Eeuo pipefail
worker_pid=""
api_pid=""
started_pid=""
state_dir="$$(mktemp -d "$${TMPDIR:-/tmp}/hub-run.XXXXXX")"
event_pipe="$$state_dir/process-events"
mkfifo "$$event_pipe"
stop_process() {
pid="$${1:-}"
name="$$2"
if [ -z "$$pid" ] || ! kill -0 "$$pid" 2>/dev/null; then return; fi
echo "Stopping $$name..."
pkill -TERM -P "$$pid" 2>/dev/null || true
kill -TERM "$$pid" 2>/dev/null || true
wait "$$pid" 2>/dev/null || true
}
cleanup() {
status=$$?
trap - INT TERM EXIT
stop_process "$$api_pid" "hub-api"
stop_process "$$worker_pid" "hub-worker"
rm -rf "$$state_dir"
exit "$$status"
}
run_and_report() {
name="$$1"
shift
(set +e; "$$@"; status=$$?; printf '%s %s\n' "$$name" "$$status" >"$$event_pipe" || true; exit "$$status") &
started_pid=$$!
}
trap cleanup EXIT
trap 'echo "Stopping hub-api and hub-worker..."; exit 130' INT
trap 'echo "Stopping hub-api and hub-worker..."; exit 143' TERM
echo "Starting hub-worker..."
run_and_report hub-worker go run ./cmd/worker
worker_pid="$$started_pid"
echo "Starting hub-api..."
run_and_report hub-api go run ./cmd/api
api_pid="$$started_pid"
read -r exited_process exit_status <"$$event_pipe"
case "$$exited_process" in
hub-worker)
echo "hub-worker exited unexpectedly with status $$exit_status; stopping hub-api."
stop_process "$$api_pid" "hub-api"
if [ "$$exit_status" -eq 0 ]; then exit 1; fi
exit "$$exit_status"
;;
hub-api)
echo "hub-api exited with status $$exit_status; stopping hub-worker."
stop_process "$$worker_pid" "hub-worker"
exit "$$exit_status"
;;
*)
echo "Unknown process event from local runner: $$exited_process" >&2
exit 1
;;
esac
endef
export RUN_LOCAL_APP

# Run the full local app: apply River migrations, then supervise hub-worker and hub-api together.
# Config: .env if present, else environment variables; env vars override .env. Copy .env.example to .env or set env vars.
run:
run: river-migrate
@bash -c "$$RUN_LOCAL_APP"

# Run the API server only (hub-api).
run-api:
@echo "Starting hub-api..."
go run ./cmd/api

# Run the worker (hub-worker). Config: .env if present, else env vars (same as run). Requires DATABASE_URL; API_KEY not required.
# Run the worker only (hub-worker). Config: .env if present, else env vars (same as run). Requires DATABASE_URL; API_KEY not required.
run-worker:
@echo "Starting hub-worker..."
go run ./cmd/worker
Expand Down Expand Up @@ -258,7 +324,7 @@ install-hooks:
dev-setup: docker-up deps install-tools init-db install-hooks
@echo "Development environment ready!"
@echo "Set API_KEY environment variable for authentication"
@echo "Run 'make run' to start the API server"
@echo "Run 'make run' to apply River migrations and start hub-api with hub-worker"

# Run Schemathesis API tests (all phases for thorough local testing)
# Phases: examples (schema examples), coverage (boundary values), stateful (API sequences), fuzzing (random)
Expand Down
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,13 @@ For a local Hub setup:
```bash
cp .env.example .env
make dev-setup
make river-migrate
make run
```

Run `make run-worker` in a separate terminal when you need webhook delivery or
embedding workers. `make dev-setup` runs the Hub schema migrations through
`make init-db`; `make river-migrate` applies River queue migrations needed by
worker-backed jobs.
`make run` applies River queue migrations and starts both `hub-api` and
`hub-worker` for local webhook delivery and embedding jobs. Use `make run-api`
or `make run-worker` only when you intentionally want to run one process on its
own. `make dev-setup` runs the Hub schema migrations through `make init-db`.

For the full Formbricks XM Suite UI, use the
[`formbricks/formbricks`](https://github.com/formbricks/formbricks) repository
Expand Down
4 changes: 2 additions & 2 deletions internal/api/handlers/feedback_records_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func (h *FeedbackRecordsHandler) Create(w http.ResponseWriter, r *http.Request)
decoder.DisallowUnknownFields()

if err := decoder.Decode(&req); err != nil {
response.RespondBadRequest(w, response.JSONDecodeErrorDetail(err))
response.RespondInvalidRequestBody(w, err)

return
}
Expand Down Expand Up @@ -167,7 +167,7 @@ func (h *FeedbackRecordsHandler) Update(w http.ResponseWriter, r *http.Request)
decoder.DisallowUnknownFields()

if err := decoder.Decode(&req); err != nil {
response.RespondBadRequest(w, response.JSONDecodeErrorDetail(err))
response.RespondInvalidRequestBody(w, err)

return
}
Expand Down
35 changes: 35 additions & 0 deletions internal/api/handlers/feedback_records_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/formbricks/hub/internal/api/response"
"github.com/formbricks/hub/internal/huberrors"
"github.com/formbricks/hub/internal/models"
)
Expand Down Expand Up @@ -112,6 +113,40 @@ func TestFeedbackRecordsHandler_Create(t *testing.T) {
assert.Equal(t, "org-123", got.TenantID)
})

t.Run("invalid field_type returns field-level problem details", func(t *testing.T) {
mock := &mockFeedbackRecordsService{}
handler := NewFeedbackRecordsHandler(mock)

body := []byte(`{
"source_type": "survey",
"field_id": "q1",
"field_type": "textt",
"tenant_id": "tenant-123",
"submission_id": "submission-123"
}`)
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "http://test/v1/feedback-records", bytes.NewReader(body))
rec := httptest.NewRecorder()

handler.Create(rec, req)

assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Contains(t, rec.Header().Get("Content-Type"), "application/problem+json")

var problem response.ProblemDetails

err := json.Unmarshal(rec.Body.Bytes(), &problem)
require.NoError(t, err)

assert.Equal(t, response.ProblemTypeValidationError, problem.Type)
assert.NotEqual(t, "about:blank", problem.Type)
assert.Equal(t, "Validation Error", problem.Title)
require.Len(t, problem.Errors, 1)
assert.Equal(t, "field_type", problem.Errors[0].Location)
assert.Equal(t, "textt", problem.Errors[0].Value)
assert.Contains(t, problem.Errors[0].Message, "text")
assert.Contains(t, problem.Errors[0].Message, "date")
})

t.Run("service validation error returns bad request", func(t *testing.T) {
mock := &mockFeedbackRecordsService{
createFunc: func(_ context.Context, _ *models.CreateFeedbackRecordRequest) (*models.FeedbackRecord, error) {
Expand Down
119 changes: 118 additions & 1 deletion internal/api/response/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,31 @@ import (
"strings"

"github.com/iancoleman/strcase"

"github.com/formbricks/hub/internal/models"
)

const (
// ProblemTypeBadRequest identifies malformed request problems.
ProblemTypeBadRequest = "https://hub.formbricks.com/problems/bad-request"
// ProblemTypeValidationError identifies request validation problems.
ProblemTypeValidationError = "https://hub.formbricks.com/problems/validation-error"
// ProblemTypeClientError identifies unclassified client-side request problems.
ProblemTypeClientError = "https://hub.formbricks.com/problems/client-error"
// ProblemTypeUnauthorized identifies authentication problems.
ProblemTypeUnauthorized = "https://hub.formbricks.com/problems/unauthorized"
// ProblemTypeNotFound identifies missing resource problems.
ProblemTypeNotFound = "https://hub.formbricks.com/problems/not-found"
// ProblemTypeConflict identifies resource conflict problems.
ProblemTypeConflict = "https://hub.formbricks.com/problems/conflict"
// ProblemTypeForbidden identifies authorization problems.
ProblemTypeForbidden = "https://hub.formbricks.com/problems/forbidden"
// ProblemTypeMethodNotAllowed identifies unsupported HTTP method problems.
ProblemTypeMethodNotAllowed = "https://hub.formbricks.com/problems/method-not-allowed"
// ProblemTypeServiceUnavailable identifies temporary dependency problems.
ProblemTypeServiceUnavailable = "https://hub.formbricks.com/problems/service-unavailable"
// ProblemTypeInternalServerError identifies unexpected server problems.
ProblemTypeInternalServerError = "https://hub.formbricks.com/problems/internal-server-error"
)

// ErrorDetail represents a single error detail in RFC 7807 Problem Details.
Expand All @@ -32,12 +57,47 @@ type ProblemDetails struct {
// RespondError writes an RFC 7807 Problem Details error response.
func RespondError(w http.ResponseWriter, statusCode int, title, detail string) {
problem := ProblemDetails{
Type: "about:blank",
Type: problemTypeForStatus(statusCode),
Title: title,
Status: statusCode,
Detail: detail,
}

respondProblem(w, statusCode, problem)
}

// RespondInvalidRequestBody writes a 400 response for JSON request body decoding failures.
func RespondInvalidRequestBody(w http.ResponseWriter, err error) {
problemType := jsonDecodeProblemType(err)

problem := ProblemDetails{
Type: problemType,
Title: jsonDecodeProblemTitle(problemType),
Status: http.StatusBadRequest,
Detail: JSONDecodeErrorDetail(err),
Errors: JSONDecodeErrorDetails(err),
}

respondProblem(w, http.StatusBadRequest, problem)
}

func jsonDecodeProblemType(err error) string {
if _, ok := invalidFieldTypeErrorDetail(err); ok {
return ProblemTypeValidationError
}

return ProblemTypeBadRequest
}

func jsonDecodeProblemTitle(problemType string) string {
if problemType == ProblemTypeValidationError {
return "Validation Error"
}

return "Bad Request"
}

func respondProblem(w http.ResponseWriter, statusCode int, problem ProblemDetails) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(statusCode)

Expand All @@ -46,6 +106,33 @@ func RespondError(w http.ResponseWriter, statusCode int, title, detail string) {
}
}

func problemTypeForStatus(statusCode int) string {
switch statusCode {
case http.StatusBadRequest:
return ProblemTypeBadRequest
case http.StatusUnauthorized:
return ProblemTypeUnauthorized
case http.StatusForbidden:
return ProblemTypeForbidden
case http.StatusMethodNotAllowed:
return ProblemTypeMethodNotAllowed
case http.StatusNotFound:
return ProblemTypeNotFound
case http.StatusConflict:
return ProblemTypeConflict
case http.StatusServiceUnavailable:
return ProblemTypeServiceUnavailable
case http.StatusInternalServerError:
return ProblemTypeInternalServerError
default:
Comment thread
xernobyl marked this conversation as resolved.
if statusCode >= http.StatusBadRequest && statusCode < http.StatusInternalServerError {
return ProblemTypeClientError
}

return ProblemTypeInternalServerError
}
}

// RespondBadRequest writes a 400 Bad Request error response.
func RespondBadRequest(w http.ResponseWriter, detail string) {
RespondError(w, http.StatusBadRequest, "Bad Request", detail)
Expand All @@ -59,6 +146,10 @@ func JSONDecodeErrorDetail(err error) string {
return "Invalid request body"
}

if detail, ok := invalidFieldTypeErrorDetail(err); ok {
return detail.Message
}

var syntaxErr *json.SyntaxError
if errors.As(err, &syntaxErr) {
return "Invalid JSON: " + err.Error()
Expand All @@ -78,6 +169,32 @@ func JSONDecodeErrorDetail(err error) string {
return "Invalid request body"
}

// JSONDecodeErrorDetails returns field-level details for JSON request body decoding failures.
func JSONDecodeErrorDetails(err error) []ErrorDetail {
if detail, ok := invalidFieldTypeErrorDetail(err); ok {
return []ErrorDetail{detail}
}

return nil
}

func invalidFieldTypeErrorDetail(err error) (ErrorDetail, bool) {
var invalidFieldType *models.InvalidFieldTypeError
if !errors.As(err, &invalidFieldType) {
return ErrorDetail{}, false
}

return ErrorDetail{
Location: "field_type",
Message: fmt.Sprintf(
"field_type has invalid value %q; must be one of: %s",
invalidFieldType.Value,
models.ValidFieldTypeValuesString(),
),
Value: invalidFieldType.Value,
}, true
}

// fieldNameForAPI converts a struct field path (e.g. "TenantID" or "X.Y") to API-style snake_case.
func fieldNameForAPI(fieldPath string) string {
if i := strings.LastIndex(fieldPath, "."); i >= 0 && i+1 < len(fieldPath) {
Expand Down
Loading
Loading