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
9 changes: 9 additions & 0 deletions example_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,15 @@
"value": "min"
}
]
},
"model.test.users.unknown.getAll": {
"supportedQueries": ["eq"]
},
"model.test.users.id.index": {
"supportedQueries": ["eq", "sort"]
},
"aggregate.test.users.email.getAggregation": {
"supportedAggregations": ["count"]
}
}
}
2 changes: 2 additions & 0 deletions src/interfaces/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ export interface ApisConfig {
webhooks?: WebhookConfig[];
serverParams?: ServerParamConfig[];
authorization?: boolean;
supportedAggregations?: Aggregation[];
supportedQueries?: QueryOperation[];
};
}

Expand Down
13 changes: 9 additions & 4 deletions src/routes/aggregate/aggregate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {FastifyInstance, FastifyReply, FastifyRequest} from 'fastify';
import {
buildPreValidation,
buildSecurityArray,
getEffectiveAggregations,
getResponseStructureSchema,
} from '@/routes/schema-helpers';

Expand All @@ -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`;

Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion src/routes/models/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
buildAllQueryProperties,
buildPreValidation,
buildSecurityArray,
getEffectiveQueries,
getResponseStructureSchema,
mapDataTypeToJsonSchema,
shouldApiBeEnabled,
Expand Down Expand Up @@ -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<string, object> = {};
const allBodyFieldNames: string[] = [];
Expand Down
6 changes: 5 additions & 1 deletion src/routes/models/get-all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
buildPreValidation,
buildSecurityArray,
generateJSONValidationSchema,
getEffectiveQueries,
getResponseStructureSchema,
shouldApiBeEnabled,
} from '@/routes/schema-helpers';
Expand Down Expand Up @@ -103,6 +104,7 @@ function registerGetAllEndpoint(
modelName,
config,
authorization,
apiIdentifier,
routeTags,
);

Expand Down Expand Up @@ -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<string, unknown> = {
summary: `Get all ${capitalizeFirstLetter(modelName)} records`,
Expand Down
8 changes: 7 additions & 1 deletion src/routes/models/index-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
buildPreValidation,
buildSecurityArray,
generateJSONValidationSchema,
getEffectiveQueries,
getResponseStructureSchema,
mapDataTypeToJsonSchema,
shouldApiBeEnabled,
Expand Down Expand Up @@ -123,6 +124,7 @@ function registerIndexEndpoint(
modelName,
config,
authorization,
apiIdentifier,
routeTags,
);

Expand Down Expand Up @@ -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<string, object> = {
data: isUnique
Expand Down
6 changes: 5 additions & 1 deletion src/routes/models/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
buildPreValidation,
buildSecurityArray,
generateJSONValidationSchema,
getEffectiveQueries,
getResponseStructureSchema,
shouldApiBeEnabled,
} from '@/routes/schema-helpers';
Expand Down Expand Up @@ -118,6 +119,7 @@ function registerSearchEndpoint(
modelName,
config,
authorization,
apiIdentifier,
routeTags,
);

Expand Down Expand Up @@ -225,14 +227,16 @@ function generateSchema(
modelName: string,
config: AppConfig,
authorization: boolean,
apiIdentifier: string,
routeTags?: string[],
) {
const effectiveQueries = getEffectiveQueries(config, apiIdentifier);
const queryProperties: Record<string, object> = {
[`${fieldName}_search`]: {
type: 'string',
description: `Search pattern to match against ${fieldName}`,
},
...buildAllQueryProperties(model),
...buildAllQueryProperties(model, effectiveQueries),
};

const schema: Record<string, unknown> = {
Expand Down
42 changes: 39 additions & 3 deletions src/routes/schema-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {FastifyInstance, FastifyReply, FastifyRequest} from 'fastify';

import {
Aggregation,
AppConfig,
DataType,
ModelBody,
ModelConfig,
ModelFieldConfig,
QueryOperation,
UpAuthProviderConfig,
} from '@/interfaces/config';

Expand Down Expand Up @@ -90,15 +92,22 @@ export function buildSortQueryProperties(
*/
export function buildAllQueryProperties(
model: ModelConfig,
effectiveQueries?: QueryOperation[],
): Record<string, object> {
const properties: Record<string, object> = {};

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));

Expand All @@ -107,15 +116,42 @@ 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.).
*/
export function buildFilterQueryProperties(
fieldName: string,
field: ModelFieldConfig,
effectiveQueries?: QueryOperation[],
): Record<string, object> {
const ops = field.query || [];
const ops = effectiveQueries
? (field.query || []).filter(op => effectiveQueries.includes(op))
: field.query || [];
const jsonType = mapDataTypeToJsonSchema(field.type);
const properties: Record<string, object> = {};

Expand Down
31 changes: 31 additions & 0 deletions src/validators/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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,
},
Expand Down
51 changes: 51 additions & 0 deletions src/validators/config/validate-apis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,57 @@ 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.")`,
);
}

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;
if (supportedAggregations && !key.startsWith('aggregate.')) {
errors.push(
`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;
Expand Down
Loading
Loading