From 4829b828e8a92f190f26bc2039fdf1807160b00c Mon Sep 17 00:00:00 2001 From: Abhishek Chatterjee Date: Thu, 30 Jul 2026 17:13:20 +0530 Subject: [PATCH 1/3] feat(apis): ROSS-211: add supportedQueries and supportedAggregations override fields to API config --- example_config.json | 9 ++ src/interfaces/config.ts | 2 + src/routes/aggregate/aggregate.ts | 13 +- src/routes/models/edit.ts | 6 +- src/routes/models/get-all.ts | 6 +- src/routes/models/index-route.ts | 8 +- src/routes/models/search.ts | 6 +- src/routes/schema-helpers.ts | 42 +++++- src/validators/config/schema.ts | 31 +++++ src/validators/config/validate-apis.ts | 17 +++ tests/routes/aggregate.test.ts | 82 +++++++++++ tests/routes/get-all.test.ts | 62 +++++++++ tests/routes/schema-helper.test.ts | 179 +++++++++++++++++++++++++ tests/validators/config.test.ts | 26 ++++ 14 files changed, 478 insertions(+), 11 deletions(-) diff --git a/example_config.json b/example_config.json index 9e613c9..540cbfa 100644 --- a/example_config.json +++ b/example_config.json @@ -228,6 +228,15 @@ "value": "min" } ] + }, + "model.test.users.unknown.getAll": { + "supportedQueries": ["eq", "lt", "gt"] + }, + "model.test.users.id.index": { + "supportedQueries": ["eq", "sort"] + }, + "aggregate.test.users.email.getAggregation": { + "supportedAggregations": ["count"] } } } diff --git a/src/interfaces/config.ts b/src/interfaces/config.ts index ded8577..cd5f5c6 100644 --- a/src/interfaces/config.ts +++ b/src/interfaces/config.ts @@ -207,6 +207,8 @@ export interface ApisConfig { webhooks?: WebhookConfig[]; serverParams?: ServerParamConfig[]; authorization?: boolean; + supportedAggregations?: Aggregation[]; + supportedQueries?: QueryOperation[]; }; } diff --git a/src/routes/aggregate/aggregate.ts b/src/routes/aggregate/aggregate.ts index d8c13ca..fd45090 100644 --- a/src/routes/aggregate/aggregate.ts +++ b/src/routes/aggregate/aggregate.ts @@ -3,6 +3,7 @@ import {FastifyInstance, FastifyReply, FastifyRequest} from 'fastify'; import { buildPreValidation, buildSecurityArray, + getEffectiveAggregations, getResponseStructureSchema, } from '@/routes/schema-helpers'; @@ -29,8 +30,6 @@ export function registerAggregateRoutes( ); for (const [fieldName, field] of aggregatableFields) { - const aggregations = field.aggregations!; - // Register default variant endpoint const defaultApiIdentifier = `aggregate${getVariantSegment(config)}.${modelName}.${fieldName}.getAggregation`; @@ -40,13 +39,16 @@ export function registerAggregateRoutes( config.authentication?.enabled ?? false; const defaultTags = config.apis?.[defaultApiIdentifier]?.tags; + const defaultAggregations = + getEffectiveAggregations(config, defaultApiIdentifier) ?? + field.aggregations!; registerAggregateEndpoint( app, config, modelName, fieldName, - aggregations, + defaultAggregations, defaultVariant, defaultApiIdentifier, defaultAuthorization, @@ -81,13 +83,16 @@ export function registerAggregateRoutes( false; const variantTags = config.apis?.[variantApiIdentifier]?.tags; + const variantAggregations = + getEffectiveAggregations(config, variantApiIdentifier) ?? + field.aggregations!; registerAggregateEndpoint( app, config, modelName, fieldName, - aggregations, + variantAggregations, variant, variantApiIdentifier, variantAuthorization, diff --git a/src/routes/models/edit.ts b/src/routes/models/edit.ts index 992b239..9131b5e 100644 --- a/src/routes/models/edit.ts +++ b/src/routes/models/edit.ts @@ -5,6 +5,7 @@ import { buildAllQueryProperties, buildPreValidation, buildSecurityArray, + getEffectiveQueries, getResponseStructureSchema, mapDataTypeToJsonSchema, shouldApiBeEnabled, @@ -119,7 +120,10 @@ function registerEditEndpoint( const isUnique = field.primaryKey || field.unique; const paramSchema = mapDataTypeToJsonSchema(field.type); - const queryProperties = isUnique ? {} : buildAllQueryProperties(model); + const effectiveQueries = getEffectiveQueries(config, apiIdentifier); + const queryProperties = isUnique + ? {} + : buildAllQueryProperties(model, effectiveQueries); const bodyProperties: Record = {}; const allBodyFieldNames: string[] = []; diff --git a/src/routes/models/get-all.ts b/src/routes/models/get-all.ts index 7ec6aee..169e4f7 100644 --- a/src/routes/models/get-all.ts +++ b/src/routes/models/get-all.ts @@ -6,6 +6,7 @@ import { buildPreValidation, buildSecurityArray, generateJSONValidationSchema, + getEffectiveQueries, getResponseStructureSchema, shouldApiBeEnabled, } from '@/routes/schema-helpers'; @@ -103,6 +104,7 @@ function registerGetAllEndpoint( modelName, config, authorization, + apiIdentifier, routeTags, ); @@ -205,9 +207,11 @@ function generateSchema( modelName: string, config: AppConfig, authorization: boolean, + apiIdentifier: string, routeTags?: string[], ) { - const queryProperties = buildAllQueryProperties(model); + const effectiveQueries = getEffectiveQueries(config, apiIdentifier); + const queryProperties = buildAllQueryProperties(model, effectiveQueries); const schema: Record = { summary: `Get all ${capitalizeFirstLetter(modelName)} records`, diff --git a/src/routes/models/index-route.ts b/src/routes/models/index-route.ts index 24953f5..bbed041 100644 --- a/src/routes/models/index-route.ts +++ b/src/routes/models/index-route.ts @@ -6,6 +6,7 @@ import { buildPreValidation, buildSecurityArray, generateJSONValidationSchema, + getEffectiveQueries, getResponseStructureSchema, mapDataTypeToJsonSchema, shouldApiBeEnabled, @@ -123,6 +124,7 @@ function registerIndexEndpoint( modelName, config, authorization, + apiIdentifier, routeTags, ); @@ -246,12 +248,16 @@ function generateSchema( modelName: string, config: AppConfig, authorization: boolean, + apiIdentifier: string, routeTags?: string[], ) { const isUnique = field.primaryKey || field.unique; const fieldSchemaType = mapDataTypeToJsonSchema(field.type); - const queryProperties = isUnique ? {} : buildAllQueryProperties(model); + const effectiveQueries = getEffectiveQueries(config, apiIdentifier); + const queryProperties = isUnique + ? {} + : buildAllQueryProperties(model, effectiveQueries); const responseSchemaProperties: Record = { data: isUnique diff --git a/src/routes/models/search.ts b/src/routes/models/search.ts index 724ae76..e6d52eb 100644 --- a/src/routes/models/search.ts +++ b/src/routes/models/search.ts @@ -6,6 +6,7 @@ import { buildPreValidation, buildSecurityArray, generateJSONValidationSchema, + getEffectiveQueries, getResponseStructureSchema, shouldApiBeEnabled, } from '@/routes/schema-helpers'; @@ -118,6 +119,7 @@ function registerSearchEndpoint( modelName, config, authorization, + apiIdentifier, routeTags, ); @@ -225,14 +227,16 @@ function generateSchema( modelName: string, config: AppConfig, authorization: boolean, + apiIdentifier: string, routeTags?: string[], ) { + const effectiveQueries = getEffectiveQueries(config, apiIdentifier); const queryProperties: Record = { [`${fieldName}_search`]: { type: 'string', description: `Search pattern to match against ${fieldName}`, }, - ...buildAllQueryProperties(model), + ...buildAllQueryProperties(model, effectiveQueries), }; const schema: Record = { diff --git a/src/routes/schema-helpers.ts b/src/routes/schema-helpers.ts index 384162a..0beb84c 100644 --- a/src/routes/schema-helpers.ts +++ b/src/routes/schema-helpers.ts @@ -1,11 +1,13 @@ import {FastifyInstance, FastifyReply, FastifyRequest} from 'fastify'; import { + Aggregation, AppConfig, DataType, ModelBody, ModelConfig, ModelFieldConfig, + QueryOperation, UpAuthProviderConfig, } from '@/interfaces/config'; @@ -90,15 +92,22 @@ export function buildSortQueryProperties( */ export function buildAllQueryProperties( model: ModelConfig, + effectiveQueries?: QueryOperation[], ): Record { const properties: Record = {}; for (const [fName, f] of Object.entries(model.fields)) { - Object.assign(properties, buildFilterQueryProperties(fName, f)); + Object.assign( + properties, + buildFilterQueryProperties(fName, f, effectiveQueries), + ); } const sortableFields = Object.entries(model.fields) - .filter(([, f]) => f.query?.includes('sort')) + .filter(([, f]) => { + if (effectiveQueries && !effectiveQueries.includes('sort')) return false; + return f.query?.includes('sort'); + }) .map(([fName]) => fName); Object.assign(properties, buildSortQueryProperties(sortableFields)); @@ -107,6 +116,30 @@ export function buildAllQueryProperties( return properties; } +/** + * Get the effective query operations for an API endpoint. + * If the API config specifies supportedQueries, they restrict which + * query operations are available. Returns undefined if no override. + */ +export function getEffectiveQueries( + config: AppConfig, + apiIdentifier: string, +): QueryOperation[] | undefined { + return config.apis?.[apiIdentifier]?.supportedQueries; +} + +/** + * Get the effective aggregations for an API endpoint. + * If the API config specifies supportedAggregations, they override + * the field-level aggregations. Returns undefined if no override. + */ +export function getEffectiveAggregations( + config: AppConfig, + apiIdentifier: string, +): Aggregation[] | undefined { + return config.apis?.[apiIdentifier]?.supportedAggregations; +} + /** * Build filter query parameter schema properties for a field * based on its query operations (lt, lte, gt, gte, eq, in, etc.). @@ -114,8 +147,11 @@ export function buildAllQueryProperties( export function buildFilterQueryProperties( fieldName: string, field: ModelFieldConfig, + effectiveQueries?: QueryOperation[], ): Record { - const ops = field.query || []; + const ops = effectiveQueries + ? (field.query || []).filter(op => effectiveQueries.includes(op)) + : field.query || []; const jsonType = mapDataTypeToJsonSchema(field.type); const properties: Record = {}; diff --git a/src/validators/config/schema.ts b/src/validators/config/schema.ts index 05ff65f..d788250 100644 --- a/src/validators/config/schema.ts +++ b/src/validators/config/schema.ts @@ -426,6 +426,19 @@ const serverParamSchema = { }, }; +const queryOperationValues = [ + 'eq', + 'ne', + 'lt', + 'lte', + 'gt', + 'gte', + 'in', + 'not_in', + 'sort', +]; +const aggregationValues = ['count', 'avg', 'sum', 'min', 'max', 'frequency']; + const apisSchema = { type: 'object', patternProperties: { @@ -457,6 +470,24 @@ const apisSchema = { authorization: { type: 'boolean', }, + supportedQueries: { + type: 'array', + items: { + type: 'string', + enum: queryOperationValues, + }, + uniqueItems: true, + minItems: 1, + }, + supportedAggregations: { + type: 'array', + items: { + type: 'string', + enum: aggregationValues, + }, + uniqueItems: true, + minItems: 1, + }, }, additionalProperties: false, }, diff --git a/src/validators/config/validate-apis.ts b/src/validators/config/validate-apis.ts index 2e82a47..ab74622 100644 --- a/src/validators/config/validate-apis.ts +++ b/src/validators/config/validate-apis.ts @@ -70,6 +70,23 @@ function validateApisConstraints(config: AppConfig): string[] { `apis/${key}/authorization: authorization is only allowed when auth is enabled`, ); } + + // validate supportedQueries is only allowed on model APIs + const supportedQueries = apisConfigurations[key]?.supportedQueries; + if (supportedQueries && !key.startsWith('model.')) { + errors.push( + `apis/${key}/supportedQueries: supportedQueries is only allowed on model APIs (keys starting with "model.")`, + ); + } + + // validate supportedAggregations is only allowed on aggregate APIs + const supportedAggregations = + apisConfigurations[key]?.supportedAggregations; + if (supportedAggregations && !key.startsWith('aggregate.')) { + errors.push( + `apis/${key}/supportedAggregations: supportedAggregations is only allowed on aggregate APIs (keys starting with "aggregate.")`, + ); + } } return errors; diff --git a/tests/routes/aggregate.test.ts b/tests/routes/aggregate.test.ts index 77a7f20..a0f755b 100644 --- a/tests/routes/aggregate.test.ts +++ b/tests/routes/aggregate.test.ts @@ -399,6 +399,88 @@ describe('test aggregate api', () => { }); }); + describe('supportedAggregations', () => { + test('should use supportedAggregations from api config instead of field-level aggregations', async () => { + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({ + rows: [{count: 5, sum: 500}], + }) // aggregation query + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + + const fastify = await createTestApp(pgConfig, aggregateModel, { + 'aggregate.v1.sales.amount.getAggregation': { + supportedAggregations: ['count', 'sum'], + }, + }); + + const response = await fastify.inject({ + method: 'GET', + url: '/v1/sales/aggregation/amount?operations=count,sum', + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.count).toBe(5); + expect(response.json().data.sum).toBe(500); + + await fastify.close(); + }); + + test('should reject operations not in supportedAggregations', async () => { + const fastify = await createTestApp(pgConfig, aggregateModel, { + 'aggregate.v1.sales.amount.getAggregation': { + supportedAggregations: ['count'], + }, + }); + + const response = await fastify.inject({ + method: 'GET', + url: '/v1/sales/aggregation/amount?operations=avg', + }); + + expect(response.statusCode).toBe(400); + + await fastify.close(); + }); + + test('should use supportedAggregations with variant endpoints', async () => { + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({ + rows: [{min: 10, max: 100}], + }) // aggregation query + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + + const fastify = await createTestApp( + pgConfig, + aggregateModel, + { + 'aggregate.admin.sales.amount.getAggregation': { + supportedAggregations: ['min', 'max'], + }, + }, + undefined, + undefined, + { + 'aggregate.v1.sales.amount.getAggregation': { + variants: ['admin'], + }, + }, + ); + + const response = await fastify.inject({ + method: 'GET', + url: '/admin/sales/aggregation/amount?operations=min,max', + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.min).toBe(10); + expect(response.json().data.max).toBe(100); + + await fastify.close(); + }); + }); + describe('API variants', () => { test('should register additional variant endpoint when apiVariants is configured', async () => { pgClientQueryMock diff --git a/tests/routes/get-all.test.ts b/tests/routes/get-all.test.ts index d64303d..c1dc422 100644 --- a/tests/routes/get-all.test.ts +++ b/tests/routes/get-all.test.ts @@ -558,6 +558,68 @@ describe('test get-all api', () => { }); }); + describe('supportedQueries', () => { + test('should work with supportedQueries restricting filter operations', async () => { + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({rows: [{total: 1}]}) // COUNT + .mockResolvedValueOnce({ + rows: [{id: 1, name: 'Alice', email: 'alice@example.com'}], + rowCount: 1, + }) + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + + const fastify = await createTestApp(pgConfig, getAllModel, { + 'model.v1.users.unknown.getAll': { + supportedQueries: ['eq'], + }, + }); + + const response = await fastify.inject({ + method: 'GET', + url: '/v1/users/?name_eq=Alice', + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.data).toHaveLength(1); + + // SQL should include WHERE for name_eq + expect(pgClientQueryMock).toHaveBeenCalledWith( + expect.stringContaining('WHERE'), + expect.arrayContaining(['Alice']), + ); + + await fastify.close(); + }); + + test('should support supportedQueries on default endpoints', async () => { + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({rows: [{total: 1}]}) // COUNT + .mockResolvedValueOnce({ + rows: [{id: 1, name: 'Bob', email: 'bob@example.com'}], + rowCount: 1, + }) + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + + const fastify = await createTestApp(pgConfig, getAllModel, { + 'model.v1.users.unknown.getAll': { + supportedQueries: ['lt', 'gt'], + }, + }); + + const response = await fastify.inject({ + method: 'GET', + url: '/v1/users/', + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.data).toHaveLength(1); + + await fastify.close(); + }); + }); + describe('API variants', () => { test('should register additional variant endpoint when apiVariants is configured', async () => { pgClientQueryMock diff --git a/tests/routes/schema-helper.test.ts b/tests/routes/schema-helper.test.ts index d44ba6e..66d62a6 100644 --- a/tests/routes/schema-helper.test.ts +++ b/tests/routes/schema-helper.test.ts @@ -2,11 +2,14 @@ import {describe, expect, it, test, vi} from 'vitest'; import { applyFilters, + buildAllQueryProperties, buildFilterQueryProperties, buildPreValidation, buildSecurityArray, buildSortQueryProperties, generateJSONValidationSchema, + getEffectiveAggregations, + getEffectiveQueries, getResponseStructureSchema, mapDataTypeToJsonSchema, stripAdditionalPostFields, @@ -20,6 +23,182 @@ import { } from '@/interfaces/config'; describe('test schema helper', () => { + // test cases for getEffectiveQueries + test('getEffectiveQueries should return undefined when no api config', () => { + const config: AppConfig = { + application: {name: 'test', logLevel: 'info'}, + docs: { + openapi: { + enabled: false, + path: '/docs', + info: {title: 'Test', version: '1.0.0'}, + }, + }, + infrastructure: { + database: {engine: 'postgres', connection: {url: 'postgresql://'}}, + }, + data: {models: {}}, + }; + expect( + getEffectiveQueries(config, 'model.v1.users.unknown.getAll'), + ).toBeUndefined(); + }); + + test('getEffectiveQueries should return supportedQueries from api config', () => { + const config: AppConfig = { + application: {name: 'test', logLevel: 'info'}, + docs: { + openapi: { + enabled: false, + path: '/docs', + info: {title: 'Test', version: '1.0.0'}, + }, + }, + infrastructure: { + database: {engine: 'postgres', connection: {url: 'postgresql://'}}, + }, + data: {models: {}}, + apis: { + 'model.v1.users.unknown.getAll': { + supportedQueries: ['lt', 'gt'], + }, + }, + }; + expect( + getEffectiveQueries(config, 'model.v1.users.unknown.getAll'), + ).toEqual(['lt', 'gt']); + }); + + test('getEffectiveQueries should return undefined for unmatched api identifier', () => { + const config: AppConfig = { + application: {name: 'test', logLevel: 'info'}, + docs: { + openapi: { + enabled: false, + path: '/docs', + info: {title: 'Test', version: '1.0.0'}, + }, + }, + infrastructure: { + database: {engine: 'postgres', connection: {url: 'postgresql://'}}, + }, + data: {models: {}}, + apis: { + 'model.v1.users.unknown.getAll': { + supportedQueries: ['lt', 'gt'], + }, + }, + }; + expect( + getEffectiveQueries(config, 'model.v1.products.unknown.getAll'), + ).toBeUndefined(); + }); + + // test cases for getEffectiveAggregations + test('getEffectiveAggregations should return undefined when no api config', () => { + const config: AppConfig = { + application: {name: 'test', logLevel: 'info'}, + docs: { + openapi: { + enabled: false, + path: '/docs', + info: {title: 'Test', version: '1.0.0'}, + }, + }, + infrastructure: { + database: {engine: 'postgres', connection: {url: 'postgresql://'}}, + }, + data: {models: {}}, + }; + expect( + getEffectiveAggregations( + config, + 'aggregate.v1.sales.amount.getAggregation', + ), + ).toBeUndefined(); + }); + + test('getEffectiveAggregations should return supportedAggregations from api config', () => { + const config: AppConfig = { + application: {name: 'test', logLevel: 'info'}, + docs: { + openapi: { + enabled: false, + path: '/docs', + info: {title: 'Test', version: '1.0.0'}, + }, + }, + infrastructure: { + database: {engine: 'postgres', connection: {url: 'postgresql://'}}, + }, + data: {models: {}}, + apis: { + 'aggregate.v1.sales.amount.getAggregation': { + supportedAggregations: ['count', 'sum'], + }, + }, + }; + expect( + getEffectiveAggregations( + config, + 'aggregate.v1.sales.amount.getAggregation', + ), + ).toEqual(['count', 'sum']); + }); + + // test cases for buildAllQueryProperties with effectiveQueries + test('buildAllQueryProperties should respect effectiveQueries to limit query operations', () => { + const model: ModelConfig = { + fields: { + id: {type: 'integer', primaryKey: true}, + name: {type: 'string', query: ['eq', 'ne', 'sort']}, + age: {type: 'integer', query: ['lt', 'gt', 'eq']}, + }, + }; + const result = buildAllQueryProperties(model, ['eq', 'lt', 'gt']); + // name should only have eq (ne filtered out) + expect(result).toHaveProperty('name_eq'); + expect(result).not.toHaveProperty('name_ne'); + // age should only have lt, gt, eq + expect(result).toHaveProperty('age_lt'); + expect(result).toHaveProperty('age_gt'); + expect(result).toHaveProperty('age_eq'); + // sort should be excluded since 'sort' is not in effectiveQueries + expect(result).not.toHaveProperty('orderBy'); + expect(result).not.toHaveProperty('orderDir'); + // pagination should always be present + expect(result).toHaveProperty('page'); + expect(result).toHaveProperty('limit'); + }); + + test('buildAllQueryProperties should include all when effectiveQueries is undefined', () => { + const model: ModelConfig = { + fields: { + name: {type: 'string', query: ['eq', 'ne', 'sort']}, + age: {type: 'integer', query: ['lt', 'gt', 'eq']}, + }, + }; + const result = buildAllQueryProperties(model); + expect(result).toHaveProperty('name_eq'); + expect(result).toHaveProperty('name_ne'); + expect(result).toHaveProperty('age_lt'); + expect(result).toHaveProperty('age_gt'); + expect(result).toHaveProperty('age_eq'); + expect(result).toHaveProperty('orderBy'); + expect(result).toHaveProperty('orderDir'); + }); + + // test cases for buildFilterQueryProperties with effectiveQueries + test('buildFilterQueryProperties should intersect field query with effectiveQueries', () => { + const field: ModelFieldConfig = { + type: 'integer', + query: ['eq', 'lt', 'gt', 'sort'], + }; + const result = buildFilterQueryProperties('age', field, ['eq', 'lt']); + expect(result).toHaveProperty('age_lt'); + expect(result).toHaveProperty('age_eq'); + expect(result).not.toHaveProperty('age_gt'); + }); // test cases for mapDataTypeToJsonSchema it.each([ {dataType: 'string', expectedSchema: {type: 'string'}}, diff --git a/tests/validators/config.test.ts b/tests/validators/config.test.ts index 2c89e38..df95855 100644 --- a/tests/validators/config.test.ts +++ b/tests/validators/config.test.ts @@ -3318,6 +3318,26 @@ describe('validateInvalidmodelConfig', () => { }, expected: 'apis/custom.v1.all.unknown.test.extra: invalid key format', }, + { + name: 'supportedQueries on non-model api', + patch: { + 'aggregate.v1.users.id.getAggregation': { + supportedQueries: ['lt', 'gt'], + }, + }, + expected: + 'apis/aggregate.v1.users.id.getAggregation/supportedQueries: supportedQueries is only allowed on model APIs (keys starting with "model.")', + }, + { + name: 'supportedAggregations on non-aggregate api', + patch: { + 'model.v1.users.unknown.getAll': { + supportedAggregations: ['count'], + }, + }, + expected: + 'apis/model.v1.users.unknown.getAll/supportedAggregations: supportedAggregations is only allowed on aggregate APIs (keys starting with "aggregate.")', + }, ])('Scenario: $name . should throw error', ({patch, expected}) => { const config = { ...validBaseConfig, @@ -3405,6 +3425,12 @@ describe('validateValidmodelConfig', () => { }, ], }, + 'model.v1.users.name.index': { + supportedQueries: ['lt', 'gt', 'eq'], + }, + 'aggregate.v1.users.name.getAggregation': { + supportedAggregations: ['count', 'sum'], + }, }, }, ])('Scenario: $name . should return', ({patch}) => { From b5ca71992d19602bc9093861c37449c55a52e6b2 Mon Sep 17 00:00:00 2001 From: Abhishek Chatterjee Date: Thu, 30 Jul 2026 17:19:48 +0530 Subject: [PATCH 2/3] feat(apis): ROSS-211: validate supportedQueries/supportedAggregations match actual field capabilities --- src/validators/config/validate-apis.ts | 34 ++++++++++++++++++++++++++ tests/validators/config.test.ts | 28 ++++++++++++++++++--- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/validators/config/validate-apis.ts b/src/validators/config/validate-apis.ts index ab74622..0e90038 100644 --- a/src/validators/config/validate-apis.ts +++ b/src/validators/config/validate-apis.ts @@ -79,6 +79,23 @@ function validateApisConstraints(config: AppConfig): string[] { ); } + if (supportedQueries && key.startsWith('model.')) { + const modelName = parts[2]; + const model = config.data.models[modelName]; + if (model) { + const allFieldQueries = new Set( + Object.values(model.fields).flatMap(f => f.query || []), + ); + for (const op of supportedQueries) { + if (!allFieldQueries.has(op)) { + errors.push( + `apis/${key}/supportedQueries: "${op}" is not supported by any field in model "${modelName}"`, + ); + } + } + } + } + // validate supportedAggregations is only allowed on aggregate APIs const supportedAggregations = apisConfigurations[key]?.supportedAggregations; @@ -87,6 +104,23 @@ function validateApisConstraints(config: AppConfig): string[] { `apis/${key}/supportedAggregations: supportedAggregations is only allowed on aggregate APIs (keys starting with "aggregate.")`, ); } + + if (supportedAggregations && key.startsWith('aggregate.')) { + const modelName = parts[2]; + const model = config.data.models[modelName]; + if (model) { + const allFieldAggregations = new Set( + Object.values(model.fields).flatMap(f => f.aggregations || []), + ); + for (const agg of supportedAggregations) { + if (!allFieldAggregations.has(agg)) { + errors.push( + `apis/${key}/supportedAggregations: "${agg}" is not supported by any field in model "${modelName}"`, + ); + } + } + } + } } return errors; diff --git a/tests/validators/config.test.ts b/tests/validators/config.test.ts index df95855..716f7f4 100644 --- a/tests/validators/config.test.ts +++ b/tests/validators/config.test.ts @@ -3338,6 +3338,26 @@ describe('validateInvalidmodelConfig', () => { expected: 'apis/model.v1.users.unknown.getAll/supportedAggregations: supportedAggregations is only allowed on aggregate APIs (keys starting with "aggregate.")', }, + { + name: 'supportedQueries with operation not supported by any field', + patch: { + 'model.v1.users.id.index': { + supportedQueries: ['in'], + }, + }, + expected: + 'apis/model.v1.users.id.index/supportedQueries: "in" is not supported by any field in model "users"', + }, + { + name: 'supportedAggregations with aggregation not supported by any field', + patch: { + 'aggregate.v1.users.id.getAggregation': { + supportedAggregations: ['avg'], + }, + }, + expected: + 'apis/aggregate.v1.users.id.getAggregation/supportedAggregations: "avg" is not supported by any field in model "users"', + }, ])('Scenario: $name . should throw error', ({patch, expected}) => { const config = { ...validBaseConfig, @@ -3425,11 +3445,11 @@ describe('validateValidmodelConfig', () => { }, ], }, - 'model.v1.users.name.index': { - supportedQueries: ['lt', 'gt', 'eq'], + 'model.v1.posts.user_id.index': { + supportedQueries: ['eq', 'in', 'lt', 'gt', 'sort'], }, - 'aggregate.v1.users.name.getAggregation': { - supportedAggregations: ['count', 'sum'], + 'aggregate.v1.posts.title.getAggregation': { + supportedAggregations: ['count'], }, }, }, From 0868070834d55aadd8d00e0b5a51d4c071d651f5 Mon Sep 17 00:00:00 2001 From: Abhishek Chatterjee Date: Thu, 30 Jul 2026 17:22:52 +0530 Subject: [PATCH 3/3] chore(example): ROSS-211: fix example_config supportedQueries to match users model fields --- example_config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/example_config.json b/example_config.json index 540cbfa..12c1e5c 100644 --- a/example_config.json +++ b/example_config.json @@ -230,7 +230,7 @@ ] }, "model.test.users.unknown.getAll": { - "supportedQueries": ["eq", "lt", "gt"] + "supportedQueries": ["eq"] }, "model.test.users.id.index": { "supportedQueries": ["eq", "sort"]