Compare commits

...

2 Commits

Author SHA1 Message Date
Dhruwang
fe08e7de1f feedback 2026-01-23 13:25:11 +05:30
Dhruwang
0d12e71f99 added response validation to client apis 2026-01-23 12:11:24 +05:30
8 changed files with 95 additions and 38 deletions

View File

@@ -8,7 +8,9 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrors } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { validateResponseData } from "@/modules/api/v2/management/responses/lib/validation";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
@@ -113,6 +115,27 @@ export const PUT = withV1ApiWrapper({
};
}
// Validate response data against validation rules (only if data is provided)
const updateData = inputValidation.data.data;
if (updateData) {
const validationErrors = validateResponseData(
survey.blocks,
updateData,
inputValidation.data.language ?? "en",
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Response validation failed",
formatValidationErrors(validationErrors),
true
),
};
}
}
// update response with quota evaluation
let updatedResponse;
try {

View File

@@ -12,6 +12,8 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { formatValidationErrors } from "@/modules/api/lib/validation";
import { validateResponseData } from "@/modules/api/v2/management/responses/lib/validation";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
@@ -123,6 +125,24 @@ export const POST = withV1ApiWrapper({
};
}
// Validate response data against validation rules
const validationErrors = validateResponseData(
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Response validation failed",
formatValidationErrors(validationErrors),
true
),
};
}
let response: TResponseWithQuotaFull;
try {
const meta: TResponseInput["meta"] = {

View File

@@ -8,10 +8,8 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { sendToPipeline } from "@/app/lib/pipelines";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import {
formatValidationErrorsForV1Api,
validateResponseData,
} from "@/modules/api/v2/management/responses/lib/validation";
import { formatValidationErrors } from "@/modules/api/lib/validation";
import { validateResponseData } from "@/modules/api/v2/management/responses/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
@@ -156,7 +154,7 @@ export const PUT = withV1ApiWrapper({
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
formatValidationErrors(validationErrors),
true
),
};

View File

@@ -7,10 +7,8 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import {
formatValidationErrorsForV1Api,
validateResponseData,
} from "@/modules/api/v2/management/responses/lib/validation";
import { formatValidationErrors } from "@/modules/api/lib/validation";
import { validateResponseData } from "@/modules/api/v2/management/responses/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import {
@@ -165,7 +163,7 @@ export const POST = withV1ApiWrapper({
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
formatValidationErrors(validationErrors),
true
),
};

View File

@@ -11,7 +11,9 @@ import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { formatValidationErrors } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { validateResponseData } from "@/modules/api/v2/management/responses/lib/validation";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { createResponseWithQuotaEvaluation } from "./lib/response";
@@ -106,6 +108,22 @@ export const POST = async (request: Request, context: Context): Promise<Response
);
}
// Validate response data against validation rules
const validationErrors = validateResponseData(
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
survey.questions
);
if (validationErrors) {
return responses.badRequestResponse(
"Response validation failed",
formatValidationErrors(validationErrors),
true
);
}
let response: TResponseWithQuotaFull;
try {
const meta: TResponseInputV2["meta"] = {

View File

@@ -0,0 +1,21 @@
import "server-only";
import { TValidationErrorMap } from "@formbricks/types/surveys/validation-rules";
/**
* Converts validation error map to API error response format as Record<string, string>
* Used by both v1 and v2 client APIs for consistent error formatting
*
* @param errorMap - Validation error map from validateResponseData
* @returns API error details as Record<string, string> where keys are field paths and values are combined error messages
*/
export const formatValidationErrors = (errorMap: TValidationErrorMap): Record<string, string> => {
const details: Record<string, string> = {};
for (const [elementId, errors] of Object.entries(errorMap)) {
// Combine all error messages for each element
const errorMessages = errors.map((error) => error.message).join("; ");
details[`response.data.${elementId}`] = errorMessages;
}
return details;
};

View File

@@ -4,11 +4,8 @@ import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TValidationErrorMap } from "@formbricks/types/surveys/validation-rules";
import {
formatValidationErrorsForApi,
formatValidationErrorsForV1Api,
validateResponseData,
} from "./validation";
import { formatValidationErrors } from "@/modules/api/lib/validation";
import { formatValidationErrorsForApi, validateResponseData } from "./validation";
const mockTransformQuestionsToBlocks = vi.fn();
const mockGetElementsFromBlocks = vi.fn();
@@ -172,13 +169,13 @@ describe("formatValidationErrorsForApi", () => {
});
});
describe("formatValidationErrorsForV1Api", () => {
test("should convert error map to V1 API format", () => {
describe("formatValidationErrors", () => {
test("should convert error map to Record format", () => {
const errorMap: TValidationErrorMap = {
element1: [{ ruleId: "minLength", ruleType: "minLength", message: "Min length required" }],
};
expect(formatValidationErrorsForV1Api(errorMap)).toEqual({
expect(formatValidationErrors(errorMap)).toEqual({
"response.data.element1": "Min length required",
});
});
@@ -191,7 +188,7 @@ describe("formatValidationErrorsForV1Api", () => {
],
};
expect(formatValidationErrorsForV1Api(errorMap)).toEqual({
expect(formatValidationErrors(errorMap)).toEqual({
"response.data.element1": "Min length; Max length",
});
});
@@ -202,7 +199,7 @@ describe("formatValidationErrorsForV1Api", () => {
element2: [{ ruleId: "maxLength", ruleType: "maxLength", message: "Max length" }],
};
expect(formatValidationErrorsForV1Api(errorMap)).toEqual({
expect(formatValidationErrors(errorMap)).toEqual({
"response.data.element1": "Min length",
"response.data.element2": "Max length",
});

View File

@@ -72,21 +72,3 @@ export const formatValidationErrorsForApi = (errorMap: TValidationErrorMap) => {
return details;
};
/**
* Converts validation error map to V1 API error response format
*
* @param errorMap - Validation error map from validateResponseData
* @returns V1 API error details as Record<string, string>
*/
export const formatValidationErrorsForV1Api = (errorMap: TValidationErrorMap): Record<string, string> => {
const details: Record<string, string> = {};
for (const [elementId, errors] of Object.entries(errorMap)) {
// Combine all error messages for each element
const errorMessages = errors.map((error) => error.message).join("; ");
details[`response.data.${elementId}`] = errorMessages;
}
return details;
};