mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 08:50:15 +01:00
🔧 fix: Chat Middleware, Zod Conversion, Auto-Save and S3 URL Refresh (#6720)
* 🔧 feat: Add configurable S3 URL refresh expiry time
* fix: Set default width and height for URLIcon component in case container style results in NaN
* refactor: Enhance auto-save functionality with debounced restore methods
* feat: Add support for additionalProperties in JSON schema conversion to Zod
* test: Add tests for additionalProperties handling in JSON schema to Zod conversion
* chore: Reorder import statements for better readability in ask route
* fix: Handle additional successful response status code (200) in SSE error handler
* fix: add missing rate limiting middleware for bedrock and agent chat routes
* fix: update moderation middleware to check feature flag before processing requests
* fix: add moderation middleware to chat routes for text moderation
* Revert "refactor: Enhance auto-save functionality with debounced restore methods"
This reverts commit d2e4134d1f.
* refactor: Move base64 encoding/decoding functions to top-level scope and optimize input handling
This commit is contained in:
parent
95ecd05046
commit
953e9732d9
12 changed files with 300 additions and 73 deletions
|
|
@ -1,10 +1,13 @@
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { ErrorTypes } = require('librechat-data-provider');
|
const { ErrorTypes } = require('librechat-data-provider');
|
||||||
|
const { isEnabled } = require('~/server/utils');
|
||||||
const denyRequest = require('./denyRequest');
|
const denyRequest = require('./denyRequest');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
||||||
async function moderateText(req, res, next) {
|
async function moderateText(req, res, next) {
|
||||||
if (process.env.OPENAI_MODERATION === 'true') {
|
if (!isEnabled(process.env.OPENAI_MODERATION)) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const { text } = req.body;
|
const { text } = req.body;
|
||||||
|
|
||||||
|
|
@ -34,7 +37,6 @@ async function moderateText(req, res, next) {
|
||||||
const errorMessage = 'error in moderation check';
|
const errorMessage = 'error in moderation check';
|
||||||
return await denyRequest(req, res, errorMessage);
|
return await denyRequest(req, res, errorMessage);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ const { PermissionTypes, Permissions } = require('librechat-data-provider');
|
||||||
const {
|
const {
|
||||||
setHeaders,
|
setHeaders,
|
||||||
handleAbort,
|
handleAbort,
|
||||||
|
moderateText,
|
||||||
// validateModel,
|
// validateModel,
|
||||||
generateCheckAccess,
|
generateCheckAccess,
|
||||||
validateConvoAccess,
|
validateConvoAccess,
|
||||||
|
|
@ -14,6 +15,7 @@ const addTitle = require('~/server/services/Endpoints/agents/title');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(moderateText);
|
||||||
router.post('/abort', handleAbort());
|
router.post('/abort', handleAbort());
|
||||||
|
|
||||||
const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
|
const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,40 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
|
||||||
const {
|
const {
|
||||||
uaParser,
|
uaParser,
|
||||||
checkBan,
|
checkBan,
|
||||||
requireJwtAuth,
|
requireJwtAuth,
|
||||||
// concurrentLimiter,
|
messageIpLimiter,
|
||||||
// messageIpLimiter,
|
concurrentLimiter,
|
||||||
// messageUserLimiter,
|
messageUserLimiter,
|
||||||
} = require('~/server/middleware');
|
} = require('~/server/middleware');
|
||||||
|
const { isEnabled } = require('~/server/utils');
|
||||||
const { v1 } = require('./v1');
|
const { v1 } = require('./v1');
|
||||||
const chat = require('./chat');
|
const chat = require('./chat');
|
||||||
|
|
||||||
|
const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
router.use(requireJwtAuth);
|
router.use(requireJwtAuth);
|
||||||
router.use(checkBan);
|
router.use(checkBan);
|
||||||
router.use(uaParser);
|
router.use(uaParser);
|
||||||
|
|
||||||
router.use('/', v1);
|
router.use('/', v1);
|
||||||
router.use('/chat', chat);
|
|
||||||
|
const chatRouter = express.Router();
|
||||||
|
if (isEnabled(LIMIT_CONCURRENT_MESSAGES)) {
|
||||||
|
chatRouter.use(concurrentLimiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEnabled(LIMIT_MESSAGE_IP)) {
|
||||||
|
chatRouter.use(messageIpLimiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEnabled(LIMIT_MESSAGE_USER)) {
|
||||||
|
chatRouter.use(messageUserLimiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
chatRouter.use('/', chat);
|
||||||
|
router.use('/chat', chatRouter);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,4 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const openAI = require('./openAI');
|
|
||||||
const custom = require('./custom');
|
|
||||||
const google = require('./google');
|
|
||||||
const anthropic = require('./anthropic');
|
|
||||||
const gptPlugins = require('./gptPlugins');
|
|
||||||
const { isEnabled } = require('~/server/utils');
|
|
||||||
const { EModelEndpoint } = require('librechat-data-provider');
|
const { EModelEndpoint } = require('librechat-data-provider');
|
||||||
const {
|
const {
|
||||||
uaParser,
|
uaParser,
|
||||||
|
|
@ -15,6 +9,12 @@ const {
|
||||||
messageUserLimiter,
|
messageUserLimiter,
|
||||||
validateConvoAccess,
|
validateConvoAccess,
|
||||||
} = require('~/server/middleware');
|
} = require('~/server/middleware');
|
||||||
|
const { isEnabled } = require('~/server/utils');
|
||||||
|
const gptPlugins = require('./gptPlugins');
|
||||||
|
const anthropic = require('./anthropic');
|
||||||
|
const custom = require('./custom');
|
||||||
|
const google = require('./google');
|
||||||
|
const openAI = require('./openAI');
|
||||||
|
|
||||||
const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};
|
const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ const router = express.Router();
|
||||||
const {
|
const {
|
||||||
setHeaders,
|
setHeaders,
|
||||||
handleAbort,
|
handleAbort,
|
||||||
|
moderateText,
|
||||||
// validateModel,
|
// validateModel,
|
||||||
// validateEndpoint,
|
// validateEndpoint,
|
||||||
buildEndpointOption,
|
buildEndpointOption,
|
||||||
|
|
@ -12,6 +13,7 @@ const { initializeClient } = require('~/server/services/Endpoints/bedrock');
|
||||||
const AgentController = require('~/server/controllers/agents/request');
|
const AgentController = require('~/server/controllers/agents/request');
|
||||||
const addTitle = require('~/server/services/Endpoints/agents/title');
|
const addTitle = require('~/server/services/Endpoints/agents/title');
|
||||||
|
|
||||||
|
router.use(moderateText);
|
||||||
router.post('/abort', handleAbort());
|
router.post('/abort', handleAbort());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,35 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
|
||||||
const {
|
const {
|
||||||
uaParser,
|
uaParser,
|
||||||
checkBan,
|
checkBan,
|
||||||
requireJwtAuth,
|
requireJwtAuth,
|
||||||
// concurrentLimiter,
|
messageIpLimiter,
|
||||||
// messageIpLimiter,
|
concurrentLimiter,
|
||||||
// messageUserLimiter,
|
messageUserLimiter,
|
||||||
} = require('~/server/middleware');
|
} = require('~/server/middleware');
|
||||||
|
const { isEnabled } = require('~/server/utils');
|
||||||
const chat = require('./chat');
|
const chat = require('./chat');
|
||||||
|
|
||||||
|
const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
router.use(requireJwtAuth);
|
router.use(requireJwtAuth);
|
||||||
router.use(checkBan);
|
router.use(checkBan);
|
||||||
router.use(uaParser);
|
router.use(uaParser);
|
||||||
|
|
||||||
|
if (isEnabled(LIMIT_CONCURRENT_MESSAGES)) {
|
||||||
|
router.use(concurrentLimiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEnabled(LIMIT_MESSAGE_IP)) {
|
||||||
|
router.use(messageIpLimiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEnabled(LIMIT_MESSAGE_USER)) {
|
||||||
|
router.use(messageUserLimiter);
|
||||||
|
}
|
||||||
|
|
||||||
router.use('/chat', chat);
|
router.use('/chat', chat);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ const bucketName = process.env.AWS_BUCKET_NAME;
|
||||||
const defaultBasePath = 'images';
|
const defaultBasePath = 'images';
|
||||||
|
|
||||||
let s3UrlExpirySeconds = 7 * 24 * 60 * 60;
|
let s3UrlExpirySeconds = 7 * 24 * 60 * 60;
|
||||||
|
let s3RefreshExpiryMs = null;
|
||||||
|
|
||||||
if (process.env.S3_URL_EXPIRY_SECONDS !== undefined) {
|
if (process.env.S3_URL_EXPIRY_SECONDS !== undefined) {
|
||||||
const parsed = parseInt(process.env.S3_URL_EXPIRY_SECONDS, 10);
|
const parsed = parseInt(process.env.S3_URL_EXPIRY_SECONDS, 10);
|
||||||
|
|
@ -29,6 +30,19 @@ if (process.env.S3_URL_EXPIRY_SECONDS !== undefined) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.env.S3_REFRESH_EXPIRY_MS !== null && process.env.S3_REFRESH_EXPIRY_MS) {
|
||||||
|
const parsed = parseInt(process.env.S3_REFRESH_EXPIRY_MS, 10);
|
||||||
|
|
||||||
|
if (!isNaN(parsed) && parsed > 0) {
|
||||||
|
s3RefreshExpiryMs = parsed;
|
||||||
|
logger.info(`[S3] Using custom refresh expiry time: ${s3RefreshExpiryMs}ms`);
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`[S3] Invalid S3_REFRESH_EXPIRY_MS value: "${process.env.S3_REFRESH_EXPIRY_MS}". Using default refresh logic.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs the S3 key based on the base path, user ID, and file name.
|
* Constructs the S3 key based on the base path, user ID, and file name.
|
||||||
*/
|
*/
|
||||||
|
|
@ -293,8 +307,16 @@ function needsRefresh(signedUrl, bufferSeconds) {
|
||||||
|
|
||||||
// Check if it's close to expiration
|
// Check if it's close to expiration
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const bufferTime = new Date(now.getTime() + bufferSeconds * 1000);
|
|
||||||
|
|
||||||
|
// If S3_REFRESH_EXPIRY_MS is set, use it to determine if URL is expired
|
||||||
|
if (s3RefreshExpiryMs !== null) {
|
||||||
|
const urlCreationTime = dateObj.getTime();
|
||||||
|
const urlAge = now.getTime() - urlCreationTime;
|
||||||
|
return urlAge >= s3RefreshExpiryMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise use the default buffer-based logic
|
||||||
|
const bufferTime = new Date(now.getTime() + bufferSeconds * 1000);
|
||||||
return expiresAtDate <= bufferTime;
|
return expiresAtDate <= bufferTime;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error checking URL expiration:', error);
|
logger.error('Error checking URL expiration:', error);
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,8 @@ export const URLIcon = memo(
|
||||||
onError={handleImageError}
|
onError={handleImageError}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
width={Number(containerStyle.width)}
|
width={Number(containerStyle.width) || 20}
|
||||||
height={Number(containerStyle.height)}
|
height={Number(containerStyle.height) || 20}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,25 +11,6 @@ const clearDraft = debounce((id?: string | null) => {
|
||||||
localStorage.removeItem(`${LocalStorageKeys.TEXT_DRAFT}${id ?? ''}`);
|
localStorage.removeItem(`${LocalStorageKeys.TEXT_DRAFT}${id ?? ''}`);
|
||||||
}, 2500);
|
}, 2500);
|
||||||
|
|
||||||
export const useAutoSave = ({
|
|
||||||
conversationId,
|
|
||||||
textAreaRef,
|
|
||||||
files,
|
|
||||||
setFiles,
|
|
||||||
}: {
|
|
||||||
conversationId?: string | null;
|
|
||||||
textAreaRef?: React.RefObject<HTMLTextAreaElement>;
|
|
||||||
files: Map<string, ExtendedFile>;
|
|
||||||
setFiles: SetterOrUpdater<Map<string, ExtendedFile>>;
|
|
||||||
}) => {
|
|
||||||
// setting for auto-save
|
|
||||||
const { setValue } = useChatFormContext();
|
|
||||||
const saveDrafts = useRecoilValue<boolean>(store.saveDrafts);
|
|
||||||
|
|
||||||
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
|
|
||||||
const fileIds = useMemo(() => Array.from(files.keys()), [files]);
|
|
||||||
const { data: fileList } = useGetFiles<TFile[]>();
|
|
||||||
|
|
||||||
const encodeBase64 = (plainText: string): string => {
|
const encodeBase64 = (plainText: string): string => {
|
||||||
try {
|
try {
|
||||||
const textBytes = new TextEncoder().encode(plainText);
|
const textBytes = new TextEncoder().encode(plainText);
|
||||||
|
|
@ -52,6 +33,25 @@ export const useAutoSave = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useAutoSave = ({
|
||||||
|
conversationId,
|
||||||
|
textAreaRef,
|
||||||
|
files,
|
||||||
|
setFiles,
|
||||||
|
}: {
|
||||||
|
conversationId?: string | null;
|
||||||
|
textAreaRef?: React.RefObject<HTMLTextAreaElement>;
|
||||||
|
files: Map<string, ExtendedFile>;
|
||||||
|
setFiles: SetterOrUpdater<Map<string, ExtendedFile>>;
|
||||||
|
}) => {
|
||||||
|
// setting for auto-save
|
||||||
|
const { setValue } = useChatFormContext();
|
||||||
|
const saveDrafts = useRecoilValue<boolean>(store.saveDrafts);
|
||||||
|
|
||||||
|
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
|
||||||
|
const fileIds = useMemo(() => Array.from(files.keys()), [files]);
|
||||||
|
const { data: fileList } = useGetFiles<TFile[]>();
|
||||||
|
|
||||||
const restoreFiles = useCallback(
|
const restoreFiles = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
const filesDraft = JSON.parse(
|
const filesDraft = JSON.parse(
|
||||||
|
|
@ -126,16 +126,17 @@ export const useAutoSave = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInput = debounce(() => {
|
const handleInput = debounce((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
if (textAreaRef?.current && textAreaRef.current.value) {
|
const value = e.target.value;
|
||||||
|
if (value) {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
`${LocalStorageKeys.TEXT_DRAFT}${conversationId}`,
|
`${LocalStorageKeys.TEXT_DRAFT}${conversationId}`,
|
||||||
encodeBase64(textAreaRef.current.value),
|
encodeBase64(value),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem(`${LocalStorageKeys.TEXT_DRAFT}${conversationId}`);
|
localStorage.removeItem(`${LocalStorageKeys.TEXT_DRAFT}${conversationId}`);
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 750);
|
||||||
|
|
||||||
const textArea = textAreaRef?.current;
|
const textArea = textAreaRef?.current;
|
||||||
if (textArea) {
|
if (textArea) {
|
||||||
|
|
|
||||||
|
|
@ -645,7 +645,7 @@ export default function useEventHandlers({
|
||||||
} else {
|
} else {
|
||||||
cancelHandler(data, submission);
|
cancelHandler(data, submission);
|
||||||
}
|
}
|
||||||
} else if (response.status === 204) {
|
} else if (response.status === 204 || response.status === 200) {
|
||||||
const responseMessage = {
|
const responseMessage = {
|
||||||
...submission.initialResponse,
|
...submission.initialResponse,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* eslint-disable jest/no-conditional-expect */
|
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
// zod.spec.ts
|
// zod.spec.ts
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
@ -468,6 +467,156 @@ describe('convertJsonSchemaToZod', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('additionalProperties handling', () => {
|
||||||
|
it('should allow any additional properties when additionalProperties is true', () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string' },
|
||||||
|
},
|
||||||
|
additionalProperties: true,
|
||||||
|
};
|
||||||
|
const zodSchema = convertJsonSchemaToZod(schema);
|
||||||
|
|
||||||
|
// Should accept the defined property
|
||||||
|
expect(zodSchema?.parse({ name: 'John' })).toEqual({ name: 'John' });
|
||||||
|
|
||||||
|
// Should also accept additional properties of any type
|
||||||
|
expect(zodSchema?.parse({ name: 'John', age: 30 })).toEqual({ name: 'John', age: 30 });
|
||||||
|
expect(zodSchema?.parse({ name: 'John', isActive: true })).toEqual({
|
||||||
|
name: 'John',
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
expect(zodSchema?.parse({ name: 'John', tags: ['tag1', 'tag2'] })).toEqual({
|
||||||
|
name: 'John',
|
||||||
|
tags: ['tag1', 'tag2'],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate additional properties according to schema when additionalProperties is an object', () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string' },
|
||||||
|
},
|
||||||
|
additionalProperties: { type: 'number' },
|
||||||
|
};
|
||||||
|
const zodSchema = convertJsonSchemaToZod(schema);
|
||||||
|
|
||||||
|
// Should accept the defined property
|
||||||
|
expect(zodSchema?.parse({ name: 'John' })).toEqual({ name: 'John' });
|
||||||
|
|
||||||
|
// Should accept additional properties that match the additionalProperties schema
|
||||||
|
expect(zodSchema?.parse({ name: 'John', age: 30, score: 100 })).toEqual({
|
||||||
|
name: 'John',
|
||||||
|
age: 30,
|
||||||
|
score: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should reject additional properties that don't match the additionalProperties schema
|
||||||
|
expect(() => zodSchema?.parse({ name: 'John', isActive: true })).toThrow();
|
||||||
|
expect(() => zodSchema?.parse({ name: 'John', tags: ['tag1', 'tag2'] })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should strip additional properties when additionalProperties is false or not specified', () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string' },
|
||||||
|
age: { type: 'number' },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
const zodSchema = convertJsonSchemaToZod(schema);
|
||||||
|
|
||||||
|
// Should accept the defined properties
|
||||||
|
expect(zodSchema?.parse({ name: 'John', age: 30 })).toEqual({ name: 'John', age: 30 });
|
||||||
|
|
||||||
|
// Current implementation strips additional properties when additionalProperties is false
|
||||||
|
const objWithExtra = { name: 'John', age: 30, isActive: true };
|
||||||
|
expect(zodSchema?.parse(objWithExtra)).toEqual({ name: 'John', age: 30 });
|
||||||
|
|
||||||
|
// Test with additionalProperties not specified (should behave the same)
|
||||||
|
const schemaWithoutAdditionalProps: JsonSchemaType = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string' },
|
||||||
|
age: { type: 'number' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const zodSchemaWithoutAdditionalProps = convertJsonSchemaToZod(schemaWithoutAdditionalProps);
|
||||||
|
|
||||||
|
expect(zodSchemaWithoutAdditionalProps?.parse({ name: 'John', age: 30 })).toEqual({
|
||||||
|
name: 'John',
|
||||||
|
age: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Current implementation strips additional properties when additionalProperties is not specified
|
||||||
|
const objWithExtra2 = { name: 'John', age: 30, isActive: true };
|
||||||
|
expect(zodSchemaWithoutAdditionalProps?.parse(objWithExtra2)).toEqual({
|
||||||
|
name: 'John',
|
||||||
|
age: 30,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex nested objects with additionalProperties', () => {
|
||||||
|
const schema: JsonSchemaType = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
user: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
name: { type: 'string' },
|
||||||
|
profile: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
bio: { type: 'string' },
|
||||||
|
},
|
||||||
|
additionalProperties: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
const zodSchema = convertJsonSchemaToZod(schema);
|
||||||
|
|
||||||
|
const validData = {
|
||||||
|
user: {
|
||||||
|
name: 'John',
|
||||||
|
profile: {
|
||||||
|
bio: 'Developer',
|
||||||
|
location: 'New York', // Additional property allowed in profile
|
||||||
|
website: 'https://example.com', // Additional property allowed in profile
|
||||||
|
},
|
||||||
|
role: 'admin', // Additional property of type string allowed in user
|
||||||
|
level: 'senior', // Additional property of type string allowed in user
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(zodSchema?.parse(validData)).toEqual(validData);
|
||||||
|
|
||||||
|
// Current implementation strips additional properties at the top level
|
||||||
|
// when additionalProperties is false
|
||||||
|
const dataWithExtraTopLevel = {
|
||||||
|
user: { name: 'John' },
|
||||||
|
extraField: 'not allowed', // This should be stripped
|
||||||
|
};
|
||||||
|
expect(zodSchema?.parse(dataWithExtraTopLevel)).toEqual({ user: { name: 'John' } });
|
||||||
|
|
||||||
|
// Should reject additional properties in user that don't match the string type
|
||||||
|
expect(() =>
|
||||||
|
zodSchema?.parse({
|
||||||
|
user: {
|
||||||
|
name: 'John',
|
||||||
|
age: 30, // Not a string
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('empty object handling', () => {
|
describe('empty object handling', () => {
|
||||||
it('should return undefined for empty object schemas when allowEmptyObject is false', () => {
|
it('should return undefined for empty object schemas when allowEmptyObject is false', () => {
|
||||||
const emptyObjectSchemas = [
|
const emptyObjectSchemas = [
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export type JsonSchemaType = {
|
||||||
properties?: Record<string, JsonSchemaType>;
|
properties?: Record<string, JsonSchemaType>;
|
||||||
required?: string[];
|
required?: string[];
|
||||||
description?: string;
|
description?: string;
|
||||||
|
additionalProperties?: boolean | JsonSchemaType;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isEmptyObjectSchema(jsonSchema?: JsonSchemaType): boolean {
|
function isEmptyObjectSchema(jsonSchema?: JsonSchemaType): boolean {
|
||||||
|
|
@ -72,7 +73,20 @@ export function convertJsonSchemaToZod(
|
||||||
} else {
|
} else {
|
||||||
objectSchema = objectSchema.partial();
|
objectSchema = objectSchema.partial();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle additionalProperties for open-ended objects
|
||||||
|
if (schema.additionalProperties === true) {
|
||||||
|
// This allows any additional properties with any type
|
||||||
|
zodSchema = objectSchema.passthrough();
|
||||||
|
} else if (typeof schema.additionalProperties === 'object') {
|
||||||
|
// For specific additional property types
|
||||||
|
const additionalSchema = convertJsonSchemaToZod(
|
||||||
|
schema.additionalProperties as JsonSchemaType,
|
||||||
|
);
|
||||||
|
zodSchema = objectSchema.catchall(additionalSchema as z.ZodType);
|
||||||
|
} else {
|
||||||
zodSchema = objectSchema;
|
zodSchema = objectSchema;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
zodSchema = z.unknown();
|
zodSchema = z.unknown();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue