Skip to content

Commit 38280cb

Browse files
authored
feat: return 422 for malformed OpenSearch query syntax (#15)
Detect OpenSearch 400 errors (e.g. parsing_exception from invalid query_string syntax) and return a 422 with INVALID_REQUEST error code instead of a generic 500.
1 parent 9e4a546 commit 38280cb

3 files changed

Lines changed: 31 additions & 1 deletion

File tree

src/routes/search.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
22

33
import { fastify, fastifyAfter, fastifyBefore, opensearch, prisma } from '../test/helpers/fastify.js';
44
import { AllPublicAccessTransformer } from '../transformers/default.js';
5+
import type { StandardErrorResponse } from '../utils/errors.js';
56
import searchRoute from './search.js';
67

78
describe('Search Route', () => {
@@ -379,6 +380,24 @@ describe('Search Route', () => {
379380
);
380381
});
381382

383+
it('should return 422 for malformed opensearch query', async () => {
384+
const opensearchError = Object.assign(new Error('parsing_exception'), { statusCode: 400 });
385+
opensearch.search.mockRejectedValue(opensearchError);
386+
387+
const response = await fastify.inject({
388+
method: 'POST',
389+
url: '/search',
390+
payload: {
391+
query: 'test',
392+
searchType: 'advanced',
393+
},
394+
});
395+
396+
expect(response.statusCode).toBe(422);
397+
const body = JSON.parse(response.body) as StandardErrorResponse;
398+
expect(body.error.code).toBe('INVALID_REQUEST');
399+
});
400+
382401
it('should handle opensearch errors', async () => {
383402
opensearch.search.mockRejectedValue(new Error('OpenSearch connection failed'));
384403

src/routes/search.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { ZodTypeProvider } from 'fastify-type-provider-zod';
55
import { z } from 'zod/v4';
66
import { baseEntityTransformer, resolveEntityReferences } from '../transformers/default.js';
77
import type { AccessTransformer, EntityTransformer } from '../types/transformers.js';
8-
import { createInternalError } from '../utils/errors.js';
8+
import { createInternalError, createInvalidRequestError } from '../utils/errors.js';
99
import { OpensearchQueryBuilder, type QueryBuilderOptions } from '../utils/queryBuilder.js';
1010

1111
const boundingBoxSchema = z.object({
@@ -183,6 +183,14 @@ const search: FastifyPluginAsync<SearchRouteOptions> = async (fastify, opts) =>
183183
return result;
184184
} catch (error) {
185185
const err = error as Error;
186+
187+
// OpenSearch returns 400 for malformed queries (e.g. invalid query_string syntax)
188+
if ('statusCode' in err && (err as { statusCode: number }).statusCode === 400) {
189+
fastify.log.warn(`Invalid search query: ${err.message}`);
190+
191+
return reply.code(422).send(createInvalidRequestError(err.message));
192+
}
193+
186194
fastify.log.error(`Search error: ${err.message}`);
187195

188196
return reply.code(500).send(createInternalError('Search failed'));

src/utils/errors.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ export const createValidationError = (message: string, violations: ValidationVio
4141
export const createNotFoundError = (message: string, entityId?: string): StandardErrorResponse =>
4242
createErrorResponse(ERROR_CODES.NOT_FOUND, message, entityId ? { entityId } : undefined);
4343

44+
export const createInvalidRequestError = (message: string): StandardErrorResponse =>
45+
createErrorResponse(ERROR_CODES.INVALID_REQUEST, message);
46+
4447
export const createInternalError = (message = 'Internal server error'): StandardErrorResponse => {
4548
return createErrorResponse(ERROR_CODES.INTERNAL_ERROR, message);
4649
};

0 commit comments

Comments
 (0)