diff --git a/example_config.json b/example_config.json index b6e10a6..1dfe8ba 100644 --- a/example_config.json +++ b/example_config.json @@ -2,6 +2,7 @@ "application": { "name": "Rocket OSS", "logLevel": "debug", + "dangerouslyOverrideDefaultVariant": "test", "rateLimit": { "enabled": true, "max": 1000, @@ -203,8 +204,13 @@ } } }, + "apiVariants": { + "aggregate.test.users.id.getAggregation": { + "variants": ["admin"] + } + }, "apis": { - "aggregate.users.id.getAggregation": { + "aggregate.admin.users.id.getAggregation": { "webhooks": [ { "url": "https://example.com", @@ -218,7 +224,7 @@ { "type": "query", "name": "operations", - "value": "[userEmail]" + "value": "min" } ] } diff --git a/src/interfaces/config.ts b/src/interfaces/config.ts index 5768377..3c4da86 100644 --- a/src/interfaces/config.ts +++ b/src/interfaces/config.ts @@ -147,6 +147,7 @@ export interface ApplicationConfig { name: string; logLevel: LogLevel; rateLimit?: RateLimitConfig; + dangerouslyOverrideDefaultVariant?: string; } export interface WebhookConfig { @@ -257,6 +258,10 @@ export interface InfrastructureConfig { cache?: CacheDbConfig; } +export interface ApiVariantEntry { + variants: string[]; +} + export interface AppConfig { application: ApplicationConfig; docs: DocsConfig; @@ -266,4 +271,5 @@ export interface AppConfig { customEndpoints?: Record; authentication?: AuthenticationConfig; integrations?: IntegrationsConfig; + apiVariants?: Record; } diff --git a/src/routes/aggregate/aggregate.ts b/src/routes/aggregate/aggregate.ts index a1849e2..91f2a43 100644 --- a/src/routes/aggregate/aggregate.ts +++ b/src/routes/aggregate/aggregate.ts @@ -8,6 +8,11 @@ import { import {Aggregation, AppConfig} from '@/interfaces/config'; +import { + buildApiIdentifier, + getAdditionalVariants, + getVariantSegment, +} from '@/utils/config'; import {capitalizeFirstLetter} from '@/utils/string'; export function registerAggregateRoutes( @@ -15,6 +20,8 @@ export function registerAggregateRoutes( config: AppConfig, ): void { const {models} = config.data; + const defaultVariant = + config.application.dangerouslyOverrideDefaultVariant ?? 'v1'; for (const [modelName, model] of Object.entries(models)) { const aggregatableFields = Object.entries(model.fields).filter( @@ -22,137 +29,204 @@ export function registerAggregateRoutes( ); for (const [fieldName, field] of aggregatableFields) { - const apiIdentifier = `aggregate.${modelName}.${fieldName}.getAggregation`; + const aggregations = field.aggregations!; - if (config.apis?.[apiIdentifier]?.enabled === false) continue; + // Register default variant endpoint + const defaultApiIdentifier = `aggregate${getVariantSegment(config)}.${modelName}.${fieldName}.getAggregation`; - const authorization = - config.apis?.[apiIdentifier]?.authorization ?? - config.authentication?.enabled ?? - false; + if (config.apis?.[defaultApiIdentifier]?.enabled !== false) { + const defaultAuthorization = + config.apis?.[defaultApiIdentifier]?.authorization ?? + config.authentication?.enabled ?? + false; - const aggregations = field.aggregations!; + registerAggregateEndpoint( + app, + config, + modelName, + fieldName, + aggregations, + defaultVariant, + defaultApiIdentifier, + defaultAuthorization, + ); + } - const schema: Record = generateSchema( - config, - fieldName, + // Register additional variant endpoints + const baseIdentifier = buildApiIdentifier( + 'aggregate', + defaultVariant, modelName, - aggregations, - authorization, + fieldName, + 'getAggregation', ); + const additionalVariants = getAdditionalVariants(config, baseIdentifier); - app.get( - `/${modelName}/aggregation/${fieldName}`, - { - schema, - config: {apiIdentifier}, - preValidation: buildPreValidation(app, config, authorization), - preHandler: async request => { - await app.callWebhook('request', request, null); - }, - onSend: async (request, _, payload) => { - await app.callWebhook('response', request, payload); - }, - }, - async (request: FastifyRequest, reply: FastifyReply) => { - const query = request.query as Record; - const requestedOps = String(query.operations || '') - .split(',') - .map(s => s.trim()) - .filter(Boolean); - - if (requestedOps.length === 0) { - return reply - .status(400) - .send( - app.buildResponse( - 400, - 'At least one aggregation operation must be provided', - null, - ), - ); - } + for (const variant of additionalVariants) { + const variantApiIdentifier = buildApiIdentifier( + 'aggregate', + variant, + modelName, + fieldName, + 'getAggregation', + ); - for (const op of requestedOps) { - if (!aggregations.includes(op as Aggregation)) { - return reply - .status(400) - .send( - app.buildResponse( - 400, - `Unsupported aggregation operation '${op}' for field ${fieldName}`, - null, - ), - ); - } - } + if (config.apis?.[variantApiIdentifier]?.enabled === false) continue; - const result: Record = {}; - - const sqlAggs: string[] = []; - if (requestedOps.includes('avg')) - sqlAggs.push(`AVG("${fieldName}") AS avg`); - if (requestedOps.includes('max')) - sqlAggs.push(`MAX("${fieldName}") AS max`); - if (requestedOps.includes('min')) - sqlAggs.push(`MIN("${fieldName}") AS min`); - if (requestedOps.includes('sum')) - sqlAggs.push(`SUM("${fieldName}") AS sum`); - if (requestedOps.includes('count')) - sqlAggs.push(`COUNT("${fieldName}") AS count`); - - let tx; - try { - tx = await app.db.beginTransaction(); - - if (sqlAggs.length > 0) { - const res = await tx.query>( - `SELECT ${sqlAggs.join(', ')} FROM "${modelName}"`, - ); - if (res.rows.length > 0) { - const row = res.rows[0]; - if (requestedOps.includes('avg')) result.avg = row.avg; - if (requestedOps.includes('max')) result.max = row.max; - if (requestedOps.includes('min')) result.min = row.min; - if (requestedOps.includes('sum')) result.sum = row.sum; - if (requestedOps.includes('count')) result.count = row.count; - } - } - - if (requestedOps.includes('frequency')) { - const freqRes = await tx.query>( - `SELECT "${fieldName}" as val, COUNT(*) as c FROM "${modelName}" GROUP BY "${fieldName}"`, - ); - const freq: Record = {}; - for (const row of freqRes.rows) { - freq[String(row.val)] = Number(row.c); - } - result.frequency = freq; - } - - await tx.commit(); - - return reply - .status(200) - .send( - app.buildResponse( - 200, - `Successfully aggregated data for ${fieldName} in ${modelName}`, - result, - ), - ); - } catch (err) { - if (tx) await tx.rollback().catch(() => {}); - throw err; - } finally { - tx?.release(); - } - }, - ); + const variantAuthorization = + config.apis?.[variantApiIdentifier]?.authorization ?? + config.authentication?.enabled ?? + false; + + registerAggregateEndpoint( + app, + config, + modelName, + fieldName, + aggregations, + variant, + variantApiIdentifier, + variantAuthorization, + ); + } } } } +/** + * Register a single aggregate endpoint for a specific variant + */ +function registerAggregateEndpoint( + app: FastifyInstance, + config: AppConfig, + modelName: string, + fieldName: string, + aggregations: Aggregation[], + variant: string, + apiIdentifier: string, + authorization: boolean, +): void { + // Build path with variant prefix + const path = `/${variant}/${modelName}/aggregation/${fieldName}`; + + const schema: Record = generateSchema( + config, + fieldName, + modelName, + aggregations, + authorization, + ); + + app.get( + path, + { + schema, + config: {apiIdentifier}, + preValidation: buildPreValidation(app, config, authorization), + preHandler: async request => { + await app.callWebhook('request', request, null); + }, + onSend: async (request, _, payload) => { + await app.callWebhook('response', request, payload); + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const query = request.query as Record; + const requestedOps = String(query.operations || '') + .split(',') + .map(s => s.trim()) + .filter(Boolean); + + if (requestedOps.length === 0) { + return reply + .status(400) + .send( + app.buildResponse( + 400, + 'At least one aggregation operation must be provided', + null, + ), + ); + } + + for (const op of requestedOps) { + if (!aggregations.includes(op as Aggregation)) { + return reply + .status(400) + .send( + app.buildResponse( + 400, + `Unsupported aggregation operation '${op}' for field ${fieldName}`, + null, + ), + ); + } + } + + const result: Record = {}; + + const sqlAggs: string[] = []; + if (requestedOps.includes('avg')) + sqlAggs.push(`AVG("${fieldName}") AS avg`); + if (requestedOps.includes('max')) + sqlAggs.push(`MAX("${fieldName}") AS max`); + if (requestedOps.includes('min')) + sqlAggs.push(`MIN("${fieldName}") AS min`); + if (requestedOps.includes('sum')) + sqlAggs.push(`SUM("${fieldName}") AS sum`); + if (requestedOps.includes('count')) + sqlAggs.push(`COUNT("${fieldName}") AS count`); + + let tx; + try { + tx = await app.db.beginTransaction(); + + if (sqlAggs.length > 0) { + const res = await tx.query>( + `SELECT ${sqlAggs.join(', ')} FROM "${modelName}"`, + ); + if (res.rows.length > 0) { + const row = res.rows[0]; + if (requestedOps.includes('avg')) result.avg = row.avg; + if (requestedOps.includes('max')) result.max = row.max; + if (requestedOps.includes('min')) result.min = row.min; + if (requestedOps.includes('sum')) result.sum = row.sum; + if (requestedOps.includes('count')) result.count = row.count; + } + } + + if (requestedOps.includes('frequency')) { + const freqRes = await tx.query>( + `SELECT "${fieldName}" as val, COUNT(*) as c FROM "${modelName}" GROUP BY "${fieldName}"`, + ); + const freq: Record = {}; + for (const row of freqRes.rows) { + freq[String(row.val)] = Number(row.c); + } + result.frequency = freq; + } + + await tx.commit(); + + return reply + .status(200) + .send( + app.buildResponse( + 200, + `Successfully aggregated data for ${fieldName} in ${modelName}`, + result, + ), + ); + } catch (err) { + if (tx) await tx.rollback().catch(() => {}); + throw err; + } finally { + tx?.release(); + } + }, + ); +} + function generateSchema( config: AppConfig, fieldName: string, diff --git a/src/routes/auth/change-email.ts b/src/routes/auth/change-email.ts index 5b1f0b0..479f249 100644 --- a/src/routes/auth/change-email.ts +++ b/src/routes/auth/change-email.ts @@ -7,6 +7,11 @@ import { import {AppConfig, UpAuthProviderConfig} from '@/interfaces/config'; +import { + buildApiIdentifier, + getAdditionalVariants, + getVariantSegment, +} from '@/utils/config'; import {capitalizeFirstLetter} from '@/utils/string'; export function registerEmailChangeRoute( @@ -14,6 +19,8 @@ export function registerEmailChangeRoute( config: AppConfig, ): void { const {models} = config.data; + const defaultVariant = + config.application.dangerouslyOverrideDefaultVariant ?? 'v1'; const upConfig = config.authentication!.provider .config as UpAuthProviderConfig; @@ -23,14 +30,70 @@ export function registerEmailChangeRoute( if (!authModelConfig) return; - const apiIdentifier = `auth.${model}.all.emailChange`; + const defaultApiIdentifier = `auth${getVariantSegment(config)}.${model}.unknown.emailChange`; + + if (config.apis?.[defaultApiIdentifier]?.enabled === false) return; - if (config.apis?.[apiIdentifier]?.enabled === false) return; + registerEmailChangeEndpoint( + app, + config, + model, + idField, + usernameField, + isVerifiedField, + defaultVariant, + defaultApiIdentifier, + ); + + const baseIdentifier = buildApiIdentifier( + 'auth', + defaultVariant, + model, + 'unknown', + 'emailChange', + ); + const additionalVariants = getAdditionalVariants(config, baseIdentifier); + + for (const variant of additionalVariants) { + const variantApiIdentifier = buildApiIdentifier( + 'auth', + variant, + model, + 'unknown', + 'emailChange', + ); + + if (config.apis?.[variantApiIdentifier]?.enabled === false) continue; + + registerEmailChangeEndpoint( + app, + config, + model, + idField, + usernameField, + isVerifiedField, + variant, + variantApiIdentifier, + ); + } +} +function registerEmailChangeEndpoint( + app: FastifyInstance, + config: AppConfig, + model: string, + idField: string, + usernameField: string, + isVerifiedField: string | undefined, + variant: string, + apiIdentifier: string, +): void { const schema: Record = generateSchema(usernameField, model); + const path = `/${variant}/auth/user/email`; + app.patch( - '/auth/user/email', + path, { schema, config: {apiIdentifier}, diff --git a/src/routes/auth/change-password.ts b/src/routes/auth/change-password.ts index 2e7d8d5..db833ce 100644 --- a/src/routes/auth/change-password.ts +++ b/src/routes/auth/change-password.ts @@ -7,6 +7,11 @@ import { import {AppConfig, UpAuthProviderConfig} from '@/interfaces/config'; +import { + buildApiIdentifier, + getAdditionalVariants, + getVariantSegment, +} from '@/utils/config'; import {compare, hash} from '@/utils/hash'; import {capitalizeFirstLetter} from '@/utils/string'; @@ -15,6 +20,8 @@ export function registerChangePasswordRoute( config: AppConfig, ): void { const {models} = config.data; + const defaultVariant = + config.application.dangerouslyOverrideDefaultVariant ?? 'v1'; const {model, idField, passwordField} = ( config.authentication!.provider.config as UpAuthProviderConfig @@ -24,14 +31,67 @@ export function registerChangePasswordRoute( if (!authModelConfig) return; - const apiIdentifier = `auth.${model}.all.changePassword`; + const defaultApiIdentifier = `auth${getVariantSegment(config)}.${model}.unknown.changePassword`; + + if (config.apis?.[defaultApiIdentifier]?.enabled === false) return; - if (config.apis?.[apiIdentifier]?.enabled === false) return; + registerChangePasswordEndpoint( + app, + config, + model, + idField, + passwordField, + defaultVariant, + defaultApiIdentifier, + ); + + const baseIdentifier = buildApiIdentifier( + 'auth', + defaultVariant, + model, + 'unknown', + 'changePassword', + ); + const additionalVariants = getAdditionalVariants(config, baseIdentifier); + + for (const variant of additionalVariants) { + const variantApiIdentifier = buildApiIdentifier( + 'auth', + variant, + model, + 'unknown', + 'changePassword', + ); + + if (config.apis?.[variantApiIdentifier]?.enabled === false) continue; + + registerChangePasswordEndpoint( + app, + config, + model, + idField, + passwordField, + variant, + variantApiIdentifier, + ); + } +} +function registerChangePasswordEndpoint( + app: FastifyInstance, + config: AppConfig, + model: string, + idField: string, + passwordField: string, + variant: string, + apiIdentifier: string, +): void { const schema: Record = generateSchema(model); + const path = `/${variant}/auth/change-password`; + app.post( - '/auth/change-password', + path, { schema, config: {apiIdentifier}, diff --git a/src/routes/auth/delete-me.ts b/src/routes/auth/delete-me.ts index 1cc51a0..cc756fb 100644 --- a/src/routes/auth/delete-me.ts +++ b/src/routes/auth/delete-me.ts @@ -7,6 +7,11 @@ import { import {AppConfig, UpAuthProviderConfig} from '@/interfaces/config'; +import { + buildApiIdentifier, + getAdditionalVariants, + getVariantSegment, +} from '@/utils/config'; import {capitalizeFirstLetter} from '@/utils/string'; export function registerDeleteMeRoute( @@ -14,6 +19,8 @@ export function registerDeleteMeRoute( config: AppConfig, ): void { const {models} = config.data; + const defaultVariant = + config.application.dangerouslyOverrideDefaultVariant ?? 'v1'; const {model, idField} = ( config.authentication!.provider.config as UpAuthProviderConfig @@ -23,14 +30,64 @@ export function registerDeleteMeRoute( if (!authModelConfig) return; - const apiIdentifier = `auth.${model}.all.deleteMe`; + const defaultApiIdentifier = `auth${getVariantSegment(config)}.${model}.unknown.deleteMe`; + + if (config.apis?.[defaultApiIdentifier]?.enabled === false) return; - if (config.apis?.[apiIdentifier]?.enabled === false) return; + registerDeleteMeEndpoint( + app, + config, + model, + idField, + defaultVariant, + defaultApiIdentifier, + ); + + const baseIdentifier = buildApiIdentifier( + 'auth', + defaultVariant, + model, + 'unknown', + 'deleteMe', + ); + const additionalVariants = getAdditionalVariants(config, baseIdentifier); + + for (const variant of additionalVariants) { + const variantApiIdentifier = buildApiIdentifier( + 'auth', + variant, + model, + 'unknown', + 'deleteMe', + ); + + if (config.apis?.[variantApiIdentifier]?.enabled === false) continue; + + registerDeleteMeEndpoint( + app, + config, + model, + idField, + variant, + variantApiIdentifier, + ); + } +} +function registerDeleteMeEndpoint( + app: FastifyInstance, + config: AppConfig, + model: string, + idField: string, + variant: string, + apiIdentifier: string, +): void { const schema: Record = generateSchema(model); + const path = `/${variant}/auth/user/me`; + app.delete( - '/auth/user/me', + path, { schema, config: {apiIdentifier}, diff --git a/src/routes/auth/edit-me.ts b/src/routes/auth/edit-me.ts index 674da69..b9abec5 100644 --- a/src/routes/auth/edit-me.ts +++ b/src/routes/auth/edit-me.ts @@ -13,6 +13,11 @@ import { UpAuthProviderConfig, } from '@/interfaces/config'; +import { + buildApiIdentifier, + getAdditionalVariants, + getVariantSegment, +} from '@/utils/config'; import {capitalizeFirstLetter} from '@/utils/string'; export function registerEditMeRoute( @@ -20,6 +25,8 @@ export function registerEditMeRoute( config: AppConfig, ): void { const {models} = config.data; + const defaultVariant = + config.application.dangerouslyOverrideDefaultVariant ?? 'v1'; const authConfig = ( config.authentication!.provider.config as UpAuthProviderConfig @@ -31,10 +38,70 @@ export function registerEditMeRoute( if (!authModelConfig) return; - const apiIdentifier = `auth.${model}.all.editMe`; + const defaultApiIdentifier = `auth${getVariantSegment(config)}.${model}.unknown.editMe`; + + if (config.apis?.[defaultApiIdentifier]?.enabled === false) return; - if (config.apis?.[apiIdentifier]?.enabled === false) return; + registerEditMeEndpoint( + app, + config, + model, + authModelConfig, + idField, + usernameField, + passwordField, + isVerifiedField, + defaultVariant, + defaultApiIdentifier, + ); + const baseIdentifier = buildApiIdentifier( + 'auth', + defaultVariant, + model, + 'unknown', + 'editMe', + ); + const additionalVariants = getAdditionalVariants(config, baseIdentifier); + + for (const variant of additionalVariants) { + const variantApiIdentifier = buildApiIdentifier( + 'auth', + variant, + model, + 'unknown', + 'editMe', + ); + + if (config.apis?.[variantApiIdentifier]?.enabled === false) continue; + + registerEditMeEndpoint( + app, + config, + model, + authModelConfig, + idField, + usernameField, + passwordField, + isVerifiedField, + variant, + variantApiIdentifier, + ); + } +} + +function registerEditMeEndpoint( + app: FastifyInstance, + config: AppConfig, + model: string, + authModelConfig: ModelConfig, + idField: string, + usernameField: string, + passwordField: string, + isVerifiedField: string | undefined, + variant: string, + apiIdentifier: string, +): void { const schema: Record = generateSchema( authModelConfig, model, @@ -44,8 +111,10 @@ export function registerEditMeRoute( isVerifiedField, ); + const path = `/${variant}/auth/user/me`; + app.patch( - '/auth/user/me', + path, { schema, config: {apiIdentifier}, diff --git a/src/routes/auth/forgot-password.ts b/src/routes/auth/forgot-password.ts index e98cbe9..5691240 100644 --- a/src/routes/auth/forgot-password.ts +++ b/src/routes/auth/forgot-password.ts @@ -4,6 +4,11 @@ import {getResponseStructureSchema} from '@/routes/schema-helpers'; import {AppConfig, UpAuthProviderConfig} from '@/interfaces/config'; +import { + buildApiIdentifier, + getAdditionalVariants, + getVariantSegment, +} from '@/utils/config'; import {capitalizeFirstLetter} from '@/utils/string'; export function registerForgotPasswordRoute( @@ -11,6 +16,8 @@ export function registerForgotPasswordRoute( config: AppConfig, ): void { const {models} = config.data; + const defaultVariant = + config.application.dangerouslyOverrideDefaultVariant ?? 'v1'; const {model, usernameField} = ( config.authentication!.provider.config as UpAuthProviderConfig @@ -20,14 +27,64 @@ export function registerForgotPasswordRoute( if (!authModelConfig) return; - const apiIdentifier = `auth.${model}.all.forgotPassword`; + const defaultApiIdentifier = `auth${getVariantSegment(config)}.${model}.unknown.forgotPassword`; - if (config.apis?.[apiIdentifier]?.enabled === false) return; + if (config.apis?.[defaultApiIdentifier]?.enabled === false) return; + registerForgotPasswordEndpoint( + app, + config, + model, + usernameField, + defaultVariant, + defaultApiIdentifier, + ); + + const baseIdentifier = buildApiIdentifier( + 'auth', + defaultVariant, + model, + 'unknown', + 'forgotPassword', + ); + const additionalVariants = getAdditionalVariants(config, baseIdentifier); + + for (const variant of additionalVariants) { + const variantApiIdentifier = buildApiIdentifier( + 'auth', + variant, + model, + 'unknown', + 'forgotPassword', + ); + + if (config.apis?.[variantApiIdentifier]?.enabled === false) continue; + + registerForgotPasswordEndpoint( + app, + config, + model, + usernameField, + variant, + variantApiIdentifier, + ); + } +} + +function registerForgotPasswordEndpoint( + app: FastifyInstance, + config: AppConfig, + model: string, + usernameField: string, + variant: string, + apiIdentifier: string, +): void { const schema: Record = generateSchema(usernameField, model); + const path = `/${variant}/auth/forgot-password`; + app.post( - '/auth/forgot-password', + path, { schema, config: {apiIdentifier}, diff --git a/src/routes/auth/login.ts b/src/routes/auth/login.ts index 75bc7c6..c4e583d 100644 --- a/src/routes/auth/login.ts +++ b/src/routes/auth/login.ts @@ -4,6 +4,11 @@ import {getResponseStructureSchema} from '@/routes/schema-helpers'; import {AppConfig, ModelBody, UpAuthProviderConfig} from '@/interfaces/config'; +import { + buildApiIdentifier, + getAdditionalVariants, + getVariantSegment, +} from '@/utils/config'; import {compare} from '@/utils/hash'; import {capitalizeFirstLetter} from '@/utils/string'; @@ -12,6 +17,8 @@ export function registerLoginRoute( config: AppConfig, ): void { const {models} = config.data; + const defaultVariant = + config.application.dangerouslyOverrideDefaultVariant ?? 'v1'; const upConfig = config.authentication!.provider .config as UpAuthProviderConfig; @@ -21,10 +28,64 @@ export function registerLoginRoute( if (!authModelConfig) return; - const apiIdentifier = `auth.${model}.all.login`; + const defaultApiIdentifier = `auth${getVariantSegment(config)}.${model}.unknown.login`; - if (config.apis?.[apiIdentifier]?.enabled === false) return; + if (config.apis?.[defaultApiIdentifier]?.enabled === false) return; + registerLoginEndpoint( + app, + config, + model, + usernameField, + passwordField, + upConfig, + defaultVariant, + defaultApiIdentifier, + ); + + const baseIdentifier = buildApiIdentifier( + 'auth', + defaultVariant, + model, + 'unknown', + 'login', + ); + const additionalVariants = getAdditionalVariants(config, baseIdentifier); + + for (const variant of additionalVariants) { + const variantApiIdentifier = buildApiIdentifier( + 'auth', + variant, + model, + 'unknown', + 'login', + ); + + if (config.apis?.[variantApiIdentifier]?.enabled === false) continue; + + registerLoginEndpoint( + app, + config, + model, + usernameField, + passwordField, + upConfig, + variant, + variantApiIdentifier, + ); + } +} + +function registerLoginEndpoint( + app: FastifyInstance, + config: AppConfig, + model: string, + usernameField: string, + passwordField: string, + upConfig: UpAuthProviderConfig, + variant: string, + apiIdentifier: string, +): void { const schema: Record = generateSchema( usernameField, passwordField, @@ -32,8 +93,10 @@ export function registerLoginRoute( upConfig.mfaRequired ?? false, ); + const path = `/${variant}/auth/login`; + app.post( - '/auth/login', + path, { schema, config: {apiIdentifier}, diff --git a/src/routes/auth/me.ts b/src/routes/auth/me.ts index 0b7200b..aafba55 100644 --- a/src/routes/auth/me.ts +++ b/src/routes/auth/me.ts @@ -8,10 +8,17 @@ import { import {AppConfig, UpAuthProviderConfig} from '@/interfaces/config'; +import { + buildApiIdentifier, + getAdditionalVariants, + getVariantSegment, +} from '@/utils/config'; import {capitalizeFirstLetter} from '@/utils/string'; export function registerMeRoute(app: FastifyInstance, config: AppConfig): void { const {models} = config.data; + const defaultVariant = + config.application.dangerouslyOverrideDefaultVariant ?? 'v1'; const {model, idField} = ( config.authentication!.provider.config as UpAuthProviderConfig @@ -21,14 +28,64 @@ export function registerMeRoute(app: FastifyInstance, config: AppConfig): void { if (!authModelConfig) return; - const apiIdentifier = `auth.${model}.all.me`; + const defaultApiIdentifier = `auth${getVariantSegment(config)}.${model}.unknown.me`; + + if (config.apis?.[defaultApiIdentifier]?.enabled === false) return; - if (config.apis?.[apiIdentifier]?.enabled === false) return; + registerMeEndpoint( + app, + config, + model, + idField, + defaultVariant, + defaultApiIdentifier, + ); + const baseIdentifier = buildApiIdentifier( + 'auth', + defaultVariant, + model, + 'unknown', + 'me', + ); + const additionalVariants = getAdditionalVariants(config, baseIdentifier); + + for (const variant of additionalVariants) { + const variantApiIdentifier = buildApiIdentifier( + 'auth', + variant, + model, + 'unknown', + 'me', + ); + + if (config.apis?.[variantApiIdentifier]?.enabled === false) continue; + + registerMeEndpoint( + app, + config, + model, + idField, + variant, + variantApiIdentifier, + ); + } +} + +function registerMeEndpoint( + app: FastifyInstance, + config: AppConfig, + model: string, + idField: string, + variant: string, + apiIdentifier: string, +): void { const schema: Record = generateSchema(model, config); + const path = `/${variant}/auth/user/me`; + app.get( - '/auth/user/me', + path, { schema, config: {apiIdentifier}, diff --git a/src/routes/auth/otp-verify.ts b/src/routes/auth/otp-verify.ts index 624b309..34d7a22 100644 --- a/src/routes/auth/otp-verify.ts +++ b/src/routes/auth/otp-verify.ts @@ -4,6 +4,11 @@ import {getResponseStructureSchema} from '@/routes/schema-helpers'; import {AppConfig, ModelBody, UpAuthProviderConfig} from '@/interfaces/config'; +import { + buildApiIdentifier, + getAdditionalVariants, + getVariantSegment, +} from '@/utils/config'; import {hash} from '@/utils/hash'; import {capitalizeFirstLetter} from '@/utils/string'; @@ -11,9 +16,11 @@ function registerOtpVerifyBase( app: FastifyInstance, config: AppConfig, path: string, - action: 'login' | 'register' | 'forgot-password', + action: 'login' | 'register' | 'forgotPassword', ): void { const {models} = config.data; + const defaultVariant = + config.application.dangerouslyOverrideDefaultVariant ?? 'v1'; const upConfig = config.authentication!.provider .config as UpAuthProviderConfig; @@ -23,18 +30,79 @@ function registerOtpVerifyBase( if (!authModelConfig) return; - const apiIdentifier = `auth.${model}.all.otp-verify-${action}`; + const operation = `otpVerify${capitalizeFirstLetter(action)}`; - if (config.apis?.[apiIdentifier]?.enabled === false) return; + const defaultApiIdentifier = `auth${getVariantSegment(config)}.${model}.unknown.${operation}`; + if (config.apis?.[defaultApiIdentifier]?.enabled === false) return; + + registerOtpVerifyEndpoint( + app, + config, + model, + usernameField, + upConfig, + action, + path, + defaultVariant, + defaultApiIdentifier, + ); + + const baseIdentifier = buildApiIdentifier( + 'auth', + defaultVariant, + model, + 'unknown', + operation, + ); + const additionalVariants = getAdditionalVariants(config, baseIdentifier); + + for (const variant of additionalVariants) { + const variantApiIdentifier = buildApiIdentifier( + 'auth', + variant, + model, + 'unknown', + operation, + ); + + if (config.apis?.[variantApiIdentifier]?.enabled === false) continue; + + registerOtpVerifyEndpoint( + app, + config, + model, + usernameField, + upConfig, + action, + path, + variant, + variantApiIdentifier, + ); + } +} + +function registerOtpVerifyEndpoint( + app: FastifyInstance, + config: AppConfig, + model: string, + usernameField: string, + upConfig: UpAuthProviderConfig, + action: 'login' | 'register' | 'forgotPassword', + path: string, + variant: string, + apiIdentifier: string, +): void { const schema: Record = generateSchema( usernameField, model, action, ); + const routePath = `/${variant}${path}`; + app.post( - path, + routePath, { schema, config: {apiIdentifier}, @@ -97,7 +165,7 @@ function registerOtpVerifyBase( .send(app.buildResponse(200, 'OTP verification successful', null)); } - if (action === 'forgot-password') { + if (action === 'forgotPassword') { const newPassword = (request.body as Record) .newPassword; /* c8 ignore start */ @@ -168,9 +236,9 @@ export function registerRegistrationOtpVerifyRoute( function generateSchema( usernameField: string, model: string, - action: 'login' | 'register' | 'forgot-password', + action: 'login' | 'register' | 'forgotPassword', ) { - const isForgotPassword = action === 'forgot-password'; + const isForgotPassword = action === 'forgotPassword'; const bodySchema = { type: 'object', @@ -204,7 +272,7 @@ function generateSchema( accessToken: {type: 'string', description: 'JWT access token'}, }, } - : action === 'forgot-password' + : action === 'forgotPassword' ? { type: 'object', properties: { @@ -237,6 +305,6 @@ export function registerForgotPasswordOtpVerifyRoute( app, config, '/auth/forgot-password/verify/otp', - 'forgot-password', + 'forgotPassword', ); } diff --git a/src/routes/auth/registration.ts b/src/routes/auth/registration.ts index d9e640c..28721c6 100644 --- a/src/routes/auth/registration.ts +++ b/src/routes/auth/registration.ts @@ -13,6 +13,11 @@ import { UpAuthProviderConfig, } from '@/interfaces/config'; +import { + buildApiIdentifier, + getAdditionalVariants, + getVariantSegment, +} from '@/utils/config'; import {hash} from '@/utils/hash'; import {capitalizeFirstLetter} from '@/utils/string'; @@ -21,6 +26,8 @@ export function registerRegistrationRoute( config: AppConfig, ): void { const {models} = config.data; + const defaultVariant = + config.application.dangerouslyOverrideDefaultVariant ?? 'v1'; const upConfig = config.authentication!.provider .config as UpAuthProviderConfig; @@ -32,10 +39,70 @@ export function registerRegistrationRoute( if (!authModelConfig) return; - const apiIdentifier = `auth.${model}.all.registration`; + const defaultApiIdentifier = `auth${getVariantSegment(config)}.${model}.unknown.registration`; + + if (config.apis?.[defaultApiIdentifier]?.enabled === false) return; - if (config.apis?.[apiIdentifier]?.enabled === false) return; + registerRegistrationEndpoint( + app, + config, + model, + authModelConfig, + passwordField, + isVerifiedField, + requiresOtp, + upConfig, + defaultVariant, + defaultApiIdentifier, + ); + const baseIdentifier = buildApiIdentifier( + 'auth', + defaultVariant, + model, + 'unknown', + 'registration', + ); + const additionalVariants = getAdditionalVariants(config, baseIdentifier); + + for (const variant of additionalVariants) { + const variantApiIdentifier = buildApiIdentifier( + 'auth', + variant, + model, + 'unknown', + 'registration', + ); + + if (config.apis?.[variantApiIdentifier]?.enabled === false) continue; + + registerRegistrationEndpoint( + app, + config, + model, + authModelConfig, + passwordField, + isVerifiedField, + requiresOtp, + upConfig, + variant, + variantApiIdentifier, + ); + } +} + +function registerRegistrationEndpoint( + app: FastifyInstance, + config: AppConfig, + model: string, + authModelConfig: ModelConfig, + passwordField: string, + isVerifiedField: string | undefined, + requiresOtp: boolean, + upConfig: UpAuthProviderConfig, + variant: string, + apiIdentifier: string, +): void { const schema: Record = generateSchema( authModelConfig, passwordField, @@ -44,8 +111,10 @@ export function registerRegistrationRoute( isVerifiedField, ); + const path = `/${variant}/auth/register`; + app.post( - '/auth/register', + path, { schema, config: {apiIdentifier}, diff --git a/src/routes/auth/resend-otp.ts b/src/routes/auth/resend-otp.ts index 5d2a3ab..4d55dd1 100644 --- a/src/routes/auth/resend-otp.ts +++ b/src/routes/auth/resend-otp.ts @@ -4,15 +4,22 @@ import {getResponseStructureSchema} from '@/routes/schema-helpers'; import {AppConfig, UpAuthProviderConfig} from '@/interfaces/config'; +import { + buildApiIdentifier, + getAdditionalVariants, + getVariantSegment, +} from '@/utils/config'; import {capitalizeFirstLetter} from '@/utils/string'; function registerResendOtpBase( app: FastifyInstance, config: AppConfig, path: string, - action: 'login' | 'register' | 'forgot-password', + action: 'login' | 'register' | 'forgotPassword', ): void { const {models} = config.data; + const defaultVariant = + config.application.dangerouslyOverrideDefaultVariant ?? 'v1'; const upConfig = config.authentication!.provider .config as UpAuthProviderConfig; @@ -22,18 +29,76 @@ function registerResendOtpBase( if (!authModelConfig) return; - const apiIdentifier = `auth.${model}.all.resend-otp-${action}`; + const operation = `resendOtp${capitalizeFirstLetter(action)}`; - if (config.apis?.[apiIdentifier]?.enabled === false) return; + const defaultApiIdentifier = `auth${getVariantSegment(config)}.${model}.unknown.${operation}`; + if (config.apis?.[defaultApiIdentifier]?.enabled === false) return; + + registerResendOtpEndpoint( + app, + config, + model, + usernameField, + action, + path, + defaultVariant, + defaultApiIdentifier, + ); + + const baseIdentifier = buildApiIdentifier( + 'auth', + defaultVariant, + model, + 'unknown', + operation, + ); + const additionalVariants = getAdditionalVariants(config, baseIdentifier); + + for (const variant of additionalVariants) { + const variantApiIdentifier = buildApiIdentifier( + 'auth', + variant, + model, + 'unknown', + operation, + ); + + if (config.apis?.[variantApiIdentifier]?.enabled === false) continue; + + registerResendOtpEndpoint( + app, + config, + model, + usernameField, + action, + path, + variant, + variantApiIdentifier, + ); + } +} + +function registerResendOtpEndpoint( + app: FastifyInstance, + config: AppConfig, + model: string, + usernameField: string, + action: string, + path: string, + variant: string, + apiIdentifier: string, +): void { const schema: Record = generateSchema( usernameField, model, action, ); + const routePath = `/${variant}${path}`; + app.post( - path, + routePath, { schema, config: {apiIdentifier}, @@ -97,7 +162,7 @@ export function registerForgotPasswordResendOtpRoute( app, config, '/auth/forgot-password/resend/otp', - 'forgot-password', + 'forgotPassword', ); } diff --git a/src/routes/custom-endpoints/custom-endpoints.ts b/src/routes/custom-endpoints/custom-endpoints.ts index 67d8795..977a3f1 100644 --- a/src/routes/custom-endpoints/custom-endpoints.ts +++ b/src/routes/custom-endpoints/custom-endpoints.ts @@ -12,6 +12,12 @@ import { import {AppConfig, CustomEndpointConfig} from '@/interfaces/config'; +import { + buildApiIdentifier, + getAdditionalVariants, + getVariantSegment, +} from '@/utils/config'; + export function registerCustomEndpointRoutes( app: FastifyInstance, config: AppConfig, @@ -20,53 +26,112 @@ export function registerCustomEndpointRoutes( if (!customEndpoints) return; + const defaultVariant = + config.application.dangerouslyOverrideDefaultVariant ?? 'v1'; + for (const [name, endpoint] of Object.entries(customEndpoints)) { - const apiIdentifier = `customEndpoints.${name}`; + const defaultApiIdentifier = `custom${getVariantSegment(config)}.all.unknown.${name}`; - if (config.apis?.[apiIdentifier]?.enabled === false) continue; + if (config.apis?.[defaultApiIdentifier]?.enabled === false) continue; - const authorization = - config.apis?.[apiIdentifier]?.authorization ?? + const defaultAuthorization = + config.apis?.[defaultApiIdentifier]?.authorization ?? config.authentication?.enabled ?? false; - const {schema, routePathSuffix} = generateSchema( + registerCustomEndpoint( + app, config, + name, endpoint, - authorization, + defaultVariant, + defaultApiIdentifier, + defaultAuthorization, ); - const routePath = `/custom-endpoints${endpoint.path.replace(/\/$/, '')}${routePathSuffix}`; - - app.route({ - method: endpoint.method, - url: routePath, - schema, - config: {apiIdentifier}, - preValidation: buildPreValidation(app, config, authorization), - preHandler: async request => { - await app.callWebhook('request', request, null); - }, - onSend: async (request, _, payload) => { - await app.callWebhook('response', request, payload); - }, - handler: async (request: FastifyRequest, reply: FastifyReply) => { - if (endpoint.handler.type === 'sql') { - return handleSql(app, request, reply, endpoint.handler.sql); - } - return reply - .status(500) - .send( - app.buildResponse( - 500, - `Handler type "${endpoint.handler.type}" not supported`, - null, - ), - ); - }, - }); + + const baseIdentifier = buildApiIdentifier( + 'custom', + defaultVariant, + 'all', + 'unknown', + name, + ); + const additionalVariants = getAdditionalVariants(config, baseIdentifier); + + for (const variant of additionalVariants) { + const variantApiIdentifier = buildApiIdentifier( + 'custom', + variant, + 'all', + 'unknown', + name, + ); + + if (config.apis?.[variantApiIdentifier]?.enabled === false) continue; + + const variantAuthorization = + config.apis?.[variantApiIdentifier]?.authorization ?? + config.authentication?.enabled ?? + false; + + registerCustomEndpoint( + app, + config, + name, + endpoint, + variant, + variantApiIdentifier, + variantAuthorization, + ); + } } } +function registerCustomEndpoint( + app: FastifyInstance, + config: AppConfig, + name: string, + endpoint: CustomEndpointConfig, + variant: string, + apiIdentifier: string, + authorization: boolean, +): void { + const {schema, routePathSuffix} = generateSchema( + config, + endpoint, + authorization, + ); + const routePath = `/${variant}/custom-endpoints${endpoint.path.replace(/\/$/, '')}${routePathSuffix}`; + + app.route({ + method: endpoint.method, + url: routePath, + schema, + config: {apiIdentifier}, + preValidation: buildPreValidation(app, config, authorization), + preHandler: async request => { + await app.callWebhook('request', request, null); + }, + onSend: async (request, _, payload) => { + await app.callWebhook('response', request, payload); + }, + handler: async (request: FastifyRequest, reply: FastifyReply) => { + if (endpoint.handler.type === 'sql') { + return handleSql(app, request, reply, endpoint.handler.sql); + } + return reply + .status(500) + .send( + app.buildResponse( + 500, + `Handler type "${endpoint.handler.type}" not supported`, + null, + ), + ); + }, + }); +} + function generateSchema( config: AppConfig, endpoint: CustomEndpointConfig, diff --git a/src/routes/models/delete.ts b/src/routes/models/delete.ts index a90330e..7a53846 100644 --- a/src/routes/models/delete.ts +++ b/src/routes/models/delete.ts @@ -10,6 +10,11 @@ import { import {AppConfig, ModelConfig, ModelFieldConfig} from '@/interfaces/config'; +import { + buildApiIdentifier, + getAdditionalVariants, + getVariantSegment, +} from '@/utils/config'; import {capitalizeFirstLetter} from '@/utils/string'; export function registerDeleteRoutes( @@ -17,6 +22,8 @@ export function registerDeleteRoutes( config: AppConfig, ): void { const {models} = config.data; + const defaultVariant = + config.application.dangerouslyOverrideDefaultVariant ?? 'v1'; for (const [modelName, model] of Object.entries(models)) { const deletableFields = Object.entries(model.fields).filter(([, f]) => @@ -24,66 +31,129 @@ export function registerDeleteRoutes( ); for (const [fieldName, field] of deletableFields) { - const apiIdentifier = `model.${modelName}.${fieldName}.delete`; + const defaultApiIdentifier = `model${getVariantSegment(config)}.${modelName}.${fieldName}.delete`; - if (!shouldApiBeEnabled(config, apiIdentifier, modelName)) continue; + if (!shouldApiBeEnabled(config, defaultApiIdentifier, modelName)) + continue; - const authorization = - config.apis?.[apiIdentifier]?.authorization ?? + const defaultAuthorization = + config.apis?.[defaultApiIdentifier]?.authorization ?? config.authentication?.enabled ?? false; - const schema: Record = generateSchema( + + registerDeleteEndpoint( + app, + config, + modelName, fieldName, field, model, - modelName, - config, - authorization, + defaultVariant, + defaultApiIdentifier, + defaultAuthorization, ); - app.delete( - `/${modelName}/${fieldName}/:${fieldName}`, - { - schema, - config: {apiIdentifier}, - preValidation: buildPreValidation(app, config, authorization), - preHandler: async request => { - await app.callWebhook('request', request, null); - }, - onSend: async (request, _, payload) => { - await app.callWebhook('response', request, payload); - }, - }, - async (request: FastifyRequest, reply: FastifyReply) => { - const {[fieldName]: value} = request.params as Record< - string, - unknown - >; - - const tableName = modelName; - const columnName = fieldName; - - const query = `DELETE FROM "${tableName}" WHERE "${columnName}" = $1;`; - - let tx; - try { - tx = await app.db.beginTransaction(); - await tx.query(query, [value]); - await tx.commit(); - } catch (err) { - if (tx) await tx.rollback().catch(() => {}); - throw err; - } finally { - tx?.release(); - } - - return reply.status(204).send(); - }, + const baseIdentifier = buildApiIdentifier( + 'model', + defaultVariant, + modelName, + fieldName, + 'delete', ); + const additionalVariants = getAdditionalVariants(config, baseIdentifier); + + for (const variant of additionalVariants) { + const variantApiIdentifier = buildApiIdentifier( + 'model', + variant, + modelName, + fieldName, + 'delete', + ); + + if (config.apis?.[variantApiIdentifier]?.enabled === false) continue; + + const variantAuthorization = + config.apis?.[variantApiIdentifier]?.authorization ?? + config.authentication?.enabled ?? + false; + + registerDeleteEndpoint( + app, + config, + modelName, + fieldName, + field, + model, + variant, + variantApiIdentifier, + variantAuthorization, + ); + } } } } +function registerDeleteEndpoint( + app: FastifyInstance, + config: AppConfig, + modelName: string, + fieldName: string, + field: ModelFieldConfig, + model: ModelConfig, + variant: string, + apiIdentifier: string, + authorization: boolean, +): void { + const schema: Record = generateSchema( + fieldName, + field, + model, + modelName, + config, + authorization, + ); + + const path = `/${variant}/${modelName}/${fieldName}/:${fieldName}`; + + app.delete( + path, + { + schema, + config: {apiIdentifier}, + preValidation: buildPreValidation(app, config, authorization), + preHandler: async request => { + await app.callWebhook('request', request, null); + }, + onSend: async (request, _, payload) => { + await app.callWebhook('response', request, payload); + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const {[fieldName]: value} = request.params as Record; + + const tableName = modelName; + const columnName = fieldName; + + const query = `DELETE FROM "${tableName}" WHERE "${columnName}" = $1;`; + + let tx; + try { + tx = await app.db.beginTransaction(); + await tx.query(query, [value]); + await tx.commit(); + } catch (err) { + if (tx) await tx.rollback().catch(() => {}); + throw err; + } finally { + tx?.release(); + } + + return reply.status(204).send(); + }, + ); +} + function generateSchema( fieldName: string, field: ModelFieldConfig, diff --git a/src/routes/models/edit.ts b/src/routes/models/edit.ts index 4372b13..0a41618 100644 --- a/src/routes/models/edit.ts +++ b/src/routes/models/edit.ts @@ -10,8 +10,18 @@ import { shouldApiBeEnabled, } from '@/routes/schema-helpers'; -import {AppConfig, ModelBody} from '@/interfaces/config'; +import { + AppConfig, + ModelBody, + ModelConfig, + ModelFieldConfig, +} from '@/interfaces/config'; +import { + buildApiIdentifier, + getAdditionalVariants, + getVariantSegment, +} from '@/utils/config'; import {capitalizeFirstLetter} from '@/utils/string'; export function registerEditRoutes( @@ -19,6 +29,8 @@ export function registerEditRoutes( config: AppConfig, ): void { const {models} = config.data; + const defaultVariant = + config.application.dangerouslyOverrideDefaultVariant ?? 'v1'; for (const [modelName, model] of Object.entries(models)) { const editableFields = Object.entries(model.fields).filter(([, f]) => @@ -26,209 +38,270 @@ export function registerEditRoutes( ); for (const [fieldName, field] of editableFields) { - const apiIdentifier = `model.${modelName}.${fieldName}.edit`; + const defaultApiIdentifier = `model${getVariantSegment(config)}.${modelName}.${fieldName}.edit`; - if (!shouldApiBeEnabled(config, apiIdentifier, modelName)) continue; + if (!shouldApiBeEnabled(config, defaultApiIdentifier, modelName)) + continue; - const authorization = - config.apis?.[apiIdentifier]?.authorization ?? + const defaultAuthorization = + config.apis?.[defaultApiIdentifier]?.authorization ?? config.authentication?.enabled ?? false; - const isUnique = field.primaryKey || field.unique; - const paramSchema = mapDataTypeToJsonSchema(field.type); - - const queryProperties = isUnique ? {} : buildAllQueryProperties(model); - - const bodyProperties: Record = {}; - const allBodyFieldNames: string[] = []; - - for (const [otherName, otherField] of Object.entries(model.fields)) { - if (otherName === fieldName) continue; - bodyProperties[otherName] = { - ...mapDataTypeToJsonSchema(otherField.type), - ...(otherField.type === 'enum' && otherField.values - ? {enum: otherField.values} - : {}), - description: `Updated value for ${otherName}`, - }; - allBodyFieldNames.push(otherName); - } - const buildRouteSchema = (method: 'PATCH' | 'PUT') => { - let finalBodySchema: Record; - - if (model.validation) { - finalBodySchema = {...model.validation}; - if (method === 'PATCH') { - delete finalBodySchema.required; - } - } else { - finalBodySchema = { - type: 'object', - properties: bodyProperties, - required: method === 'PUT' ? allBodyFieldNames : [], - additionalProperties: false, - }; - } - - const responseDataSchema = {...finalBodySchema} as Record< - string, - unknown - >; - if (method === 'PATCH' && responseDataSchema.required) { - delete responseDataSchema.required; - } - - const schema: Record = { - summary: `${method === 'PATCH' ? 'Partial' : 'Complete'} edit of ${capitalizeFirstLetter(modelName)} record(s) by ${fieldName}`, - description: `${method} update on records from the database by ${fieldName}`, - tags: [capitalizeFirstLetter(modelName), 'Update'], - params: { - type: 'object', - properties: { - [fieldName]: { - ...paramSchema, - description: `The ${fieldName} value identifying the record to edit`, - }, - }, - required: [fieldName], - additionalProperties: false, - }, - body: finalBodySchema, - response: getResponseStructureSchema([200], responseDataSchema), - }; + registerEditEndpoint( + app, + config, + modelName, + fieldName, + field, + model, + defaultVariant, + defaultApiIdentifier, + defaultAuthorization, + ); - const security = buildSecurityArray(config, authorization); + const baseIdentifier = buildApiIdentifier( + 'model', + defaultVariant, + modelName, + fieldName, + 'edit', + ); + const additionalVariants = getAdditionalVariants(config, baseIdentifier); + + for (const variant of additionalVariants) { + const variantApiIdentifier = buildApiIdentifier( + 'model', + variant, + modelName, + fieldName, + 'edit', + ); + + if (config.apis?.[variantApiIdentifier]?.enabled === false) continue; + + const variantAuthorization = + config.apis?.[variantApiIdentifier]?.authorization ?? + config.authentication?.enabled ?? + false; + + registerEditEndpoint( + app, + config, + modelName, + fieldName, + field, + model, + variant, + variantApiIdentifier, + variantAuthorization, + ); + } + } + } +} - if (security.length > 0) { - schema.security = security; - } +function registerEditEndpoint( + app: FastifyInstance, + config: AppConfig, + modelName: string, + fieldName: string, + field: ModelFieldConfig, + model: ModelConfig, + variant: string, + apiIdentifier: string, + authorization: boolean, +): void { + const isUnique = field.primaryKey || field.unique; + const paramSchema = mapDataTypeToJsonSchema(field.type); + + const queryProperties = isUnique ? {} : buildAllQueryProperties(model); + + const bodyProperties: Record = {}; + const allBodyFieldNames: string[] = []; + + for (const [otherName, otherField] of Object.entries(model.fields)) { + if (otherName === fieldName) continue; + bodyProperties[otherName] = { + ...mapDataTypeToJsonSchema(otherField.type), + ...(otherField.type === 'enum' && otherField.values + ? {enum: otherField.values} + : {}), + description: `Updated value for ${otherName}`, + }; + allBodyFieldNames.push(otherName); + } - if (Object.keys(queryProperties).length > 0) { - schema.querystring = { - type: 'object', - properties: queryProperties, - additionalProperties: false, - }; - } + const buildRouteSchema = (method: 'PATCH' | 'PUT') => { + let finalBodySchema: Record; - return schema; + if (model.validation) { + finalBodySchema = {...model.validation}; + if (method === 'PATCH') { + delete finalBodySchema.required; + } + } else { + finalBodySchema = { + type: 'object', + properties: bodyProperties, + required: method === 'PUT' ? allBodyFieldNames : [], + additionalProperties: false, }; + } - const handleEditRequest = async ( - request: FastifyRequest, - reply: FastifyReply, - ) => { - const queryParams = request.query as Record; - const params = request.params as Record; - const tableName = modelName; - const body = request.body as ModelBody; - - delete body[fieldName]; - - const keys = Object.keys(body); - if (keys.length === 0) { - return reply - .status(400) - .send({error: 'No fields provided for update'}); - } - - const values: unknown[] = []; - let paramIndex = 1; - - const setClauses: string[] = []; - for (const key of keys) { - setClauses.push(`"${key}" = $${paramIndex++}`); - values.push(body[key]); - } - - const whereClauses: string[] = []; - whereClauses.push(`"${fieldName}" = $${paramIndex++}`); - values.push(params[fieldName]); - - if (!isUnique) { - const { - whereClauses: filterClauses, - values: filterValues, - nextParamIndex, - } = applyFilters(queryParams, paramIndex, [fieldName]); - - whereClauses.push(...filterClauses); - values.push(...filterValues); - paramIndex = nextParamIndex; - } - - const query = `UPDATE "${tableName}" SET ${setClauses.join(', ')} WHERE ${whereClauses.join(' AND ')}`; - - let tx; - try { - tx = await app.db.beginTransaction(); - const res = await tx.query(query, values); - await tx.commit(); - - const affected = res.changes; - - if (affected !== undefined && affected === 0) { - return reply - .status(404) - .send( - app.buildResponse( - 404, - `No ${tableName} record found matching the given criteria`, - null, - ), - ); - } - - return reply - .status(200) - .send( - app.buildResponse( - 200, - `Successfully updated records in the ${tableName} table`, - body, - res, - ), - ); - } catch (err) { - if (tx) await tx.rollback().catch(() => {}); - throw err; - } finally { - tx?.release(); - } - }; + const responseDataSchema = {...finalBodySchema} as Record; + if (method === 'PATCH' && responseDataSchema.required) { + delete responseDataSchema.required; + } - app.patch( - `/${modelName}/${fieldName}/:${fieldName}`, - { - schema: buildRouteSchema('PATCH'), - config: {apiIdentifier}, - preValidation: buildPreValidation(app, config, authorization), - preHandler: async request => { - await app.callWebhook('request', request, null); - }, - onSend: async (request, _, payload) => { - await app.callWebhook('response', request, payload); + const schema: Record = { + summary: `${method === 'PATCH' ? 'Partial' : 'Complete'} edit of ${capitalizeFirstLetter(modelName)} record(s) by ${fieldName}`, + description: `${method} update on records from the database by ${fieldName}`, + tags: [capitalizeFirstLetter(modelName), 'Update'], + params: { + type: 'object', + properties: { + [fieldName]: { + ...paramSchema, + description: `The ${fieldName} value identifying the record to edit`, }, }, - handleEditRequest, - ); + required: [fieldName], + additionalProperties: false, + }, + body: finalBodySchema, + response: getResponseStructureSchema([200], responseDataSchema), + }; - app.put( - `/${modelName}/${fieldName}/:${fieldName}`, - { - schema: buildRouteSchema('PUT'), - config: {apiIdentifier}, - preValidation: buildPreValidation(app, config, authorization), - preHandler: async request => { - await app.callWebhook('request', request, null); - }, - onSend: async (request, _, payload) => { - await app.callWebhook('response', request, payload); - }, - }, - handleEditRequest, - ); + const security = buildSecurityArray(config, authorization); + + if (security.length > 0) { + schema.security = security; } - } + + if (Object.keys(queryProperties).length > 0) { + schema.querystring = { + type: 'object', + properties: queryProperties, + additionalProperties: false, + }; + } + + return schema; + }; + + const handleEditRequest = async ( + request: FastifyRequest, + reply: FastifyReply, + ) => { + const queryParams = request.query as Record; + const params = request.params as Record; + const tableName = modelName; + const body = request.body as ModelBody; + + delete body[fieldName]; + + const keys = Object.keys(body); + if (keys.length === 0) { + return reply.status(400).send({error: 'No fields provided for update'}); + } + + const values: unknown[] = []; + let paramIndex = 1; + + const setClauses: string[] = []; + for (const key of keys) { + setClauses.push(`"${key}" = $${paramIndex++}`); + values.push(body[key]); + } + + const whereClauses: string[] = []; + whereClauses.push(`"${fieldName}" = $${paramIndex++}`); + values.push(params[fieldName]); + + if (!isUnique) { + const { + whereClauses: filterClauses, + values: filterValues, + nextParamIndex, + } = applyFilters(queryParams, paramIndex, [fieldName]); + + whereClauses.push(...filterClauses); + values.push(...filterValues); + paramIndex = nextParamIndex; + } + + const query = `UPDATE "${tableName}" SET ${setClauses.join(', ')} WHERE ${whereClauses.join(' AND ')}`; + + let tx; + try { + tx = await app.db.beginTransaction(); + const res = await tx.query(query, values); + await tx.commit(); + + const affected = res.changes; + + if (affected !== undefined && affected === 0) { + return reply + .status(404) + .send( + app.buildResponse( + 404, + `No ${tableName} record found matching the given criteria`, + null, + ), + ); + } + + return reply + .status(200) + .send( + app.buildResponse( + 200, + `Successfully updated records in the ${tableName} table`, + body, + res, + ), + ); + } catch (err) { + if (tx) await tx.rollback().catch(() => {}); + throw err; + } finally { + tx?.release(); + } + }; + + const basePath = `/${variant}/${modelName}/${fieldName}/:${fieldName}`; + + app.patch( + basePath, + { + schema: buildRouteSchema('PATCH'), + config: {apiIdentifier}, + preValidation: buildPreValidation(app, config, authorization), + preHandler: async request => { + await app.callWebhook('request', request, null); + }, + onSend: async (request, _, payload) => { + await app.callWebhook('response', request, payload); + }, + }, + handleEditRequest, + ); + + app.put( + basePath, + { + schema: buildRouteSchema('PUT'), + config: {apiIdentifier}, + preValidation: buildPreValidation(app, config, authorization), + preHandler: async request => { + await app.callWebhook('request', request, null); + }, + onSend: async (request, _, payload) => { + await app.callWebhook('response', request, payload); + }, + }, + handleEditRequest, + ); } diff --git a/src/routes/models/get-all.ts b/src/routes/models/get-all.ts index f17f3a9..9c4e5e6 100644 --- a/src/routes/models/get-all.ts +++ b/src/routes/models/get-all.ts @@ -12,6 +12,11 @@ import { import {AppConfig, ModelConfig} from '@/interfaces/config'; +import { + buildApiIdentifier, + getAdditionalVariants, + getVariantSegment, +} from '@/utils/config'; import {capitalizeFirstLetter} from '@/utils/string'; export function registerGetAllRoutes( @@ -19,116 +24,176 @@ export function registerGetAllRoutes( config: AppConfig, ): void { const {models} = config.data; + const defaultVariant = + config.application.dangerouslyOverrideDefaultVariant ?? 'v1'; for (const [modelName, model] of Object.entries(models)) { - const apiIdentifier = `model.${modelName}.all.getAll`; + const defaultApiIdentifier = `model${getVariantSegment(config)}.${modelName}.unknown.getAll`; - if (!shouldApiBeEnabled(config, apiIdentifier, modelName)) continue; + if (!shouldApiBeEnabled(config, defaultApiIdentifier, modelName)) continue; - const authorization = - config.apis?.[apiIdentifier]?.authorization ?? + const defaultAuthorization = + config.apis?.[defaultApiIdentifier]?.authorization ?? config.authentication?.enabled ?? false; - const schema: Record = generateSchema( + registerGetAllEndpoint( + app, + config, + modelName, model, + defaultVariant, + defaultApiIdentifier, + defaultAuthorization, + ); + + const baseIdentifier = buildApiIdentifier( + 'model', + defaultVariant, modelName, - config, - authorization, + 'unknown', + 'getAll', ); + const additionalVariants = getAdditionalVariants(config, baseIdentifier); + + for (const variant of additionalVariants) { + const variantApiIdentifier = buildApiIdentifier( + 'model', + variant, + modelName, + 'unknown', + 'getAll', + ); + + if (config.apis?.[variantApiIdentifier]?.enabled === false) continue; + + const variantAuthorization = + config.apis?.[variantApiIdentifier]?.authorization ?? + config.authentication?.enabled ?? + false; + + registerGetAllEndpoint( + app, + config, + modelName, + model, + variant, + variantApiIdentifier, + variantAuthorization, + ); + } + } +} - app.get( - `/${modelName}/`, - { - schema, - config: {apiIdentifier}, - preValidation: buildPreValidation(app, config, authorization), - preHandler: async request => { - await app.callWebhook('request', request, null); - }, - onSend: async (request, _, payload) => { - await app.callWebhook('response', request, payload); - }, +function registerGetAllEndpoint( + app: FastifyInstance, + config: AppConfig, + modelName: string, + model: ModelConfig, + variant: string, + apiIdentifier: string, + authorization: boolean, +): void { + const schema: Record = generateSchema( + model, + modelName, + config, + authorization, + ); + + const path = `/${variant}/${modelName}/`; + + app.get( + path, + { + schema, + config: {apiIdentifier}, + preValidation: buildPreValidation(app, config, authorization), + preHandler: async request => { + await app.callWebhook('request', request, null); }, - async (request: FastifyRequest, reply: FastifyReply) => { - const queryParams = request.query as Record; - const tableName = modelName; + onSend: async (request, _, payload) => { + await app.callWebhook('response', request, payload); + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const queryParams = request.query as Record; + const tableName = modelName; + + let query = `SELECT * FROM "${tableName}"`; + const values: unknown[] = []; + let paramIndex = 1; + + const whereClauses: string[] = []; + + const { + whereClauses: filterClauses, + values: filterValues, + nextParamIndex, + } = applyFilters(queryParams, paramIndex); - let query = `SELECT * FROM "${tableName}"`; - const values: unknown[] = []; - let paramIndex = 1; + whereClauses.push(...filterClauses); + values.push(...filterValues); + paramIndex = nextParamIndex; - const whereClauses: string[] = []; + if (whereClauses.length > 0) { + query += ` WHERE ${whereClauses.join(' AND ')}`; + } - const { - whereClauses: filterClauses, - values: filterValues, - nextParamIndex, - } = applyFilters(queryParams, paramIndex); + const countQuery = `SELECT COUNT(*) as total FROM "${tableName}"${whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : ''}`; - whereClauses.push(...filterClauses); - values.push(...filterValues); - paramIndex = nextParamIndex; + let tx; + try { + tx = await app.db.beginTransaction(); - if (whereClauses.length > 0) { - query += ` WHERE ${whereClauses.join(' AND ')}`; + const countRes = await tx.query<{total: number | string}>( + countQuery, + filterValues, + ); + const total = Number(countRes.rows[0]?.total || 0); + + if (queryParams.orderBy) { + query += ` ORDER BY "${queryParams.orderBy}" ${queryParams.orderDir === 'desc' ? 'DESC' : 'ASC'}`; } - const countQuery = `SELECT COUNT(*) as total FROM "${tableName}"${whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : ''}`; - - let tx; - try { - tx = await app.db.beginTransaction(); - - const countRes = await tx.query<{total: number | string}>( - countQuery, - filterValues, - ); - const total = Number(countRes.rows[0]?.total || 0); - - if (queryParams.orderBy) { - query += ` ORDER BY "${queryParams.orderBy}" ${queryParams.orderDir === 'desc' ? 'DESC' : 'ASC'}`; - } - - const page = Math.max(Number(queryParams.page) || 1, 1); - const limit = Math.min( - Math.max(Number(queryParams.limit) || 20, 10), - 100, - ); - const offset = (page - 1) * limit; - - query += ` LIMIT $${paramIndex++} OFFSET $${paramIndex++};`; - values.push(limit, offset); - - const res = await tx.query(query, values); - - await tx.commit(); - - return reply.status(200).send( - app.buildResponse( - 200, - `Successfully retrieved records from the ${tableName} table`, - { - data: res.rows || [], - pagination: { - page, - limit, - total, - totalPages: Math.ceil(total / limit), - }, + const page = Math.max(Number(queryParams.page) || 1, 1); + const limit = Math.min( + Math.max(Number(queryParams.limit) || 20, 10), + 100, + ); + const offset = (page - 1) * limit; + + query += ` LIMIT $${paramIndex++} OFFSET $${paramIndex++};`; + values.push(limit, offset); + + const res = await tx.query(query, values); + + await tx.commit(); + + return reply.status(200).send( + app.buildResponse( + 200, + `Successfully retrieved records from the ${tableName} table`, + { + data: res.rows || [], + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), }, - res, - ), - ); - } catch (err) { - if (tx) await tx.rollback().catch(() => {}); - throw err; - } finally { - tx?.release(); - } - }, - ); - } + }, + res, + ), + ); + } catch (err) { + if (tx) await tx.rollback().catch(() => {}); + throw err; + } finally { + tx?.release(); + } + }, + ); } function generateSchema( model: ModelConfig, diff --git a/src/routes/models/index-route.ts b/src/routes/models/index-route.ts index 2b5c6f7..8612108 100644 --- a/src/routes/models/index-route.ts +++ b/src/routes/models/index-route.ts @@ -13,6 +13,11 @@ import { import {AppConfig, ModelConfig, ModelFieldConfig} from '@/interfaces/config'; +import { + buildApiIdentifier, + getAdditionalVariants, + getVariantSegment, +} from '@/utils/config'; import {capitalizeFirstLetter} from '@/utils/string'; export function registerIndexRoutes( @@ -20,6 +25,8 @@ export function registerIndexRoutes( config: AppConfig, ): void { const {models} = config.data; + const defaultVariant = + config.application.dangerouslyOverrideDefaultVariant ?? 'v1'; for (const [modelName, model] of Object.entries(models)) { const indexFields = Object.entries(model.fields).filter(([, f]) => { @@ -27,141 +34,197 @@ export function registerIndexRoutes( }); for (const [fieldName, field] of indexFields) { - const apiIdentifier = `model.${modelName}.${fieldName}.index`; + const defaultApiIdentifier = `model${getVariantSegment(config)}.${modelName}.${fieldName}.index`; - if (!shouldApiBeEnabled(config, apiIdentifier, modelName)) continue; + if (!shouldApiBeEnabled(config, defaultApiIdentifier, modelName)) + continue; - const authorization = - config.apis?.[apiIdentifier]?.authorization ?? + const defaultAuthorization = + config.apis?.[defaultApiIdentifier]?.authorization ?? config.authentication?.enabled ?? false; - const { - schema, - isUnique, - }: {schema: Record; isUnique: boolean | undefined} = - generateSchema( + + registerIndexEndpoint( + app, + config, + modelName, + fieldName, + field, + model, + defaultVariant, + defaultApiIdentifier, + defaultAuthorization, + ); + + const baseIdentifier = buildApiIdentifier( + 'model', + defaultVariant, + modelName, + fieldName, + 'index', + ); + const additionalVariants = getAdditionalVariants(config, baseIdentifier); + + for (const variant of additionalVariants) { + const variantApiIdentifier = buildApiIdentifier( + 'model', + variant, + modelName, + fieldName, + 'index', + ); + + if (config.apis?.[variantApiIdentifier]?.enabled === false) continue; + + const variantAuthorization = + config.apis?.[variantApiIdentifier]?.authorization ?? + config.authentication?.enabled ?? + false; + + registerIndexEndpoint( + app, + config, + modelName, fieldName, field, model, - modelName, - config, - authorization, + variant, + variantApiIdentifier, + variantAuthorization, ); + } + } + } +} - app.get( - `/${modelName}/${fieldName}/:${fieldName}`, - { - schema, - config: {apiIdentifier}, - preValidation: buildPreValidation(app, config, authorization), - preHandler: async request => { - await app.callWebhook('request', request, null); - }, - onSend: async (request, _, payload) => { - await app.callWebhook('response', request, payload); - }, - }, - async (request: FastifyRequest, reply: FastifyReply) => { - const queryParams = request.query as Record; - const params = request.params as Record; - const tableName = modelName; - - let query = `SELECT * FROM "${tableName}"`; - const values: unknown[] = []; - let paramIndex = 1; - - const whereClauses: string[] = []; - - whereClauses.push(`"${fieldName}" = $${paramIndex++}`); - values.push(params[fieldName]); - - if (!isUnique) { - const { - whereClauses: filterClauses, - values: filterValues, - nextParamIndex, - } = applyFilters(queryParams, paramIndex); - - whereClauses.push(...filterClauses); - values.push(...filterValues); - paramIndex = nextParamIndex; +function registerIndexEndpoint( + app: FastifyInstance, + config: AppConfig, + modelName: string, + fieldName: string, + field: ModelFieldConfig, + model: ModelConfig, + variant: string, + apiIdentifier: string, + authorization: boolean, +): void { + const { + schema, + isUnique, + }: {schema: Record; isUnique: boolean | undefined} = + generateSchema(fieldName, field, model, modelName, config, authorization); + + const path = `/${variant}/${modelName}/${fieldName}/:${fieldName}`; + + app.get( + path, + { + schema, + config: {apiIdentifier}, + preValidation: buildPreValidation(app, config, authorization), + preHandler: async request => { + await app.callWebhook('request', request, null); + }, + onSend: async (request, _, payload) => { + await app.callWebhook('response', request, payload); + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const queryParams = request.query as Record; + const params = request.params as Record; + const tableName = modelName; + + let query = `SELECT * FROM "${tableName}"`; + const values: unknown[] = []; + let paramIndex = 1; + + const whereClauses: string[] = []; + + whereClauses.push(`"${fieldName}" = $${paramIndex++}`); + values.push(params[fieldName]); + + if (!isUnique) { + const { + whereClauses: filterClauses, + values: filterValues, + nextParamIndex, + } = applyFilters(queryParams, paramIndex); + + whereClauses.push(...filterClauses); + values.push(...filterValues); + paramIndex = nextParamIndex; + } + + query += ` WHERE ${whereClauses.join(' AND ')}`; + + let tx; + try { + tx = await app.db.beginTransaction(); + + let total = 0; + if (!isUnique) { + const countQuery = `SELECT COUNT(*) as total FROM "${tableName}" WHERE ${whereClauses.join(' AND ')}`; + const countRes = await tx.query<{total: number | string}>( + countQuery, + values, + ); + total = Number(countRes.rows[0]?.total || 0); + } + + let page = 1; + let limit = 20; + + if (!isUnique) { + if (queryParams.orderBy) { + query += ` ORDER BY "${queryParams.orderBy}" ${queryParams.orderDir === 'desc' ? 'DESC' : 'ASC'}`; } - query += ` WHERE ${whereClauses.join(' AND ')}`; - - let tx; - try { - tx = await app.db.beginTransaction(); - - let total = 0; - if (!isUnique) { - const countQuery = `SELECT COUNT(*) as total FROM "${tableName}" WHERE ${whereClauses.join(' AND ')}`; - const countRes = await tx.query<{total: number | string}>( - countQuery, - values, - ); - total = Number(countRes.rows[0]?.total || 0); - } - - let page = 1; - let limit = 20; - - if (!isUnique) { - if (queryParams.orderBy) { - query += ` ORDER BY "${queryParams.orderBy}" ${queryParams.orderDir === 'desc' ? 'DESC' : 'ASC'}`; - } - - page = Math.max(Number(queryParams.page) || 1, 1); - limit = Math.min( - Math.max(Number(queryParams.limit) || 20, 10), - 100, - ); - const offset = (page - 1) * limit; - - query += ` LIMIT $${paramIndex++} OFFSET $${paramIndex++};`; - values.push(limit, offset); - } else { - query += ` LIMIT $${paramIndex++};`; - values.push(1); - } - - const res = await tx.query(query, values); - - await tx.commit(); - - const responsePayload: Record = { - data: isUnique ? res.rows[0] || null : res.rows || [], - }; - - if (!isUnique) { - responsePayload.pagination = { - page, - limit, - total, - totalPages: Math.ceil(total / limit), - }; - } - - return reply - .status(200) - .send( - app.buildResponse( - 200, - `Successfully retrieved records from the ${tableName} table`, - responsePayload, - res, - ), - ); - } catch (err) { - if (tx) await tx.rollback().catch(() => {}); - throw err; - } finally { - tx?.release(); - } - }, - ); - } - } + page = Math.max(Number(queryParams.page) || 1, 1); + limit = Math.min(Math.max(Number(queryParams.limit) || 20, 10), 100); + const offset = (page - 1) * limit; + + query += ` LIMIT $${paramIndex++} OFFSET $${paramIndex++};`; + values.push(limit, offset); + } else { + query += ` LIMIT $${paramIndex++};`; + values.push(1); + } + + const res = await tx.query(query, values); + + await tx.commit(); + + const responsePayload: Record = { + data: isUnique ? res.rows[0] || null : res.rows || [], + }; + + if (!isUnique) { + responsePayload.pagination = { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }; + } + + return reply + .status(200) + .send( + app.buildResponse( + 200, + `Successfully retrieved records from the ${tableName} table`, + responsePayload, + res, + ), + ); + } catch (err) { + if (tx) await tx.rollback().catch(() => {}); + throw err; + } finally { + tx?.release(); + } + }, + ); } function generateSchema( diff --git a/src/routes/models/post.ts b/src/routes/models/post.ts index 18f8594..a9076dc 100644 --- a/src/routes/models/post.ts +++ b/src/routes/models/post.ts @@ -11,6 +11,11 @@ import { import {AppConfig, ModelBody, ModelConfig} from '@/interfaces/config'; +import { + buildApiIdentifier, + getAdditionalVariants, + getVariantSegment, +} from '@/utils/config'; import {capitalizeFirstLetter} from '@/utils/string'; export function registerPostRoutes( @@ -18,78 +23,136 @@ export function registerPostRoutes( config: AppConfig, ): void { const {models} = config.data; + const defaultVariant = + config.application.dangerouslyOverrideDefaultVariant ?? 'v1'; for (const [modelName, model] of Object.entries(models)) { - const apiIdentifier = `model.${modelName}.all.insert`; + const defaultApiIdentifier = `model${getVariantSegment(config)}.${modelName}.unknown.insert`; - if (!shouldApiBeEnabled(config, apiIdentifier, modelName)) continue; + if (!shouldApiBeEnabled(config, defaultApiIdentifier, modelName)) continue; - const authorization = - config.apis?.[apiIdentifier]?.authorization ?? + const defaultAuthorization = + config.apis?.[defaultApiIdentifier]?.authorization ?? config.authentication?.enabled ?? false; - const schema: Record = generateSchema( + registerPostEndpoint( + app, + config, + modelName, model, + defaultVariant, + defaultApiIdentifier, + defaultAuthorization, + ); + + const baseIdentifier = buildApiIdentifier( + 'model', + defaultVariant, modelName, - config, - authorization, + 'unknown', + 'insert', ); + const additionalVariants = getAdditionalVariants(config, baseIdentifier); + + for (const variant of additionalVariants) { + const variantApiIdentifier = buildApiIdentifier( + 'model', + variant, + modelName, + 'unknown', + 'insert', + ); + + if (config.apis?.[variantApiIdentifier]?.enabled === false) continue; + + const variantAuthorization = + config.apis?.[variantApiIdentifier]?.authorization ?? + config.authentication?.enabled ?? + false; + + registerPostEndpoint( + app, + config, + modelName, + model, + variant, + variantApiIdentifier, + variantAuthorization, + ); + } + } +} - app.post( - `/${modelName}/`, - { - schema, - config: {apiIdentifier}, - preValidation: buildPreValidation(app, config, authorization), - preHandler: async request => { - await app.callWebhook('request', request, null); - }, - onSend: async (request, _, payload) => { - await app.callWebhook('response', request, payload); - }, +function registerPostEndpoint( + app: FastifyInstance, + config: AppConfig, + modelName: string, + model: ModelConfig, + variant: string, + apiIdentifier: string, + authorization: boolean, +): void { + const schema: Record = generateSchema( + model, + modelName, + config, + authorization, + ); + + const path = `/${variant}/${modelName}/`; + + app.post( + path, + { + schema, + config: {apiIdentifier}, + preValidation: buildPreValidation(app, config, authorization), + preHandler: async request => { + await app.callWebhook('request', request, null); }, - async (request: FastifyRequest, reply: FastifyReply) => { - const tableName = modelName; - const incomingBody = request.body as ModelBody; - - const body = stripAdditionalPostFields(model, incomingBody, { - ignorePrimaryKey: true, - }); - const keys = Object.keys(body); - const values = Object.values(body); - - const columns = keys.map(key => `"${key}"`).join(', '); - const placeholders = values - .map((_, index) => `$${index + 1}`) - .join(', '); - const query = `INSERT INTO "${tableName}" (${columns}) VALUES (${placeholders});`; - - let tx; - try { - tx = await app.db.beginTransaction(); - const res = await tx.query(query, values); - await tx.commit(); - - return reply - .status(201) - .send( - app.buildResponse( - 201, - `Successfully added the new entry to the ${tableName} table`, - body, - res, - ), - ); - } catch (err) { - if (tx) await tx.rollback().catch(() => {}); - throw err; - } finally { - tx?.release(); - } + onSend: async (request, _, payload) => { + await app.callWebhook('response', request, payload); }, - ); - } + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const tableName = modelName; + const incomingBody = request.body as ModelBody; + + const body = stripAdditionalPostFields(model, incomingBody, { + ignorePrimaryKey: true, + }); + const keys = Object.keys(body); + const values = Object.values(body); + + const columns = keys.map(key => `"${key}"`).join(', '); + const placeholders = values.map((_, index) => `$${index + 1}`).join(', '); + const query = `INSERT INTO "${tableName}" (${columns}) VALUES (${placeholders});`; + + let tx; + try { + tx = await app.db.beginTransaction(); + const res = await tx.query(query, values); + await tx.commit(); + + return reply + .status(201) + .send( + app.buildResponse( + 201, + `Successfully added the new entry to the ${tableName} table`, + body, + res, + ), + ); + } catch (err) { + if (tx) await tx.rollback().catch(() => {}); + throw err; + } finally { + tx?.release(); + } + }, + ); } function generateSchema( model: ModelConfig, diff --git a/src/routes/models/search.ts b/src/routes/models/search.ts index d76791c..603e07f 100644 --- a/src/routes/models/search.ts +++ b/src/routes/models/search.ts @@ -12,6 +12,11 @@ import { import {AppConfig, ModelConfig, ModelFieldConfig} from '@/interfaces/config'; +import { + buildApiIdentifier, + getAdditionalVariants, + getVariantSegment, +} from '@/utils/config'; import {capitalizeFirstLetter} from '@/utils/string'; export function registerSearchRoutes( @@ -19,6 +24,8 @@ export function registerSearchRoutes( config: AppConfig, ): void { const {models} = config.data; + const defaultVariant = + config.application.dangerouslyOverrideDefaultVariant ?? 'v1'; for (const [modelName, model] of Object.entries(models)) { const searchableFields = Object.entries(model.fields).filter(([, f]) => @@ -26,121 +33,186 @@ export function registerSearchRoutes( ); for (const [fieldName, field] of searchableFields) { - const apiIdentifier = `model.${modelName}.${fieldName}.search`; + const defaultApiIdentifier = `model${getVariantSegment(config)}.${modelName}.${fieldName}.search`; - if (!shouldApiBeEnabled(config, apiIdentifier, modelName)) continue; + if (!shouldApiBeEnabled(config, defaultApiIdentifier, modelName)) + continue; - const authorization = - config.apis?.[apiIdentifier]?.authorization ?? + const defaultAuthorization = + config.apis?.[defaultApiIdentifier]?.authorization ?? config.authentication?.enabled ?? false; - const schema: Record = generateSchema( + registerSearchEndpoint( + app, + config, + modelName, fieldName, field, model, - modelName, - config, - authorization, + defaultVariant, + defaultApiIdentifier, + defaultAuthorization, ); - app.get( - `/${modelName}/search/${fieldName}`, - { - schema, - config: {apiIdentifier}, - preValidation: buildPreValidation(app, config, authorization), - preHandler: async request => { - await app.callWebhook('request', request, null); - }, - onSend: async (request, _, payload) => { - await app.callWebhook('response', request, payload); - }, - }, - async (request: FastifyRequest, reply: FastifyReply) => { - const queryParams = request.query as Record; - const tableName = modelName; - - let query = `SELECT * FROM "${tableName}"`; - const values: unknown[] = []; - let paramIndex = 1; - - const whereClauses: string[] = []; - - const searchTerm = String(queryParams[`${fieldName}_search`] || ''); - whereClauses.push(`LOWER("${fieldName}") LIKE $${paramIndex++}`); - values.push(`%${searchTerm.toLowerCase()}%`); - - const { - whereClauses: filterClauses, - values: filterValues, - nextParamIndex, - } = applyFilters(queryParams, paramIndex, [`${fieldName}_search`]); - - whereClauses.push(...filterClauses); - values.push(...filterValues); - paramIndex = nextParamIndex; - - query += ` WHERE ${whereClauses.join(' AND ')}`; - - const countQuery = `SELECT COUNT(*) as total FROM "${tableName}" WHERE ${whereClauses.join(' AND ')}`; - - let tx; - try { - tx = await app.db.beginTransaction(); - - const countRes = await tx.query<{total: number | string}>( - countQuery, - values, - ); - const total = Number(countRes.rows[0]?.total || 0); - - if (queryParams.orderBy) { - query += ` ORDER BY "${queryParams.orderBy}" ${queryParams.orderDir === 'desc' ? 'DESC' : 'ASC'}`; - } - - const page = Math.max(Number(queryParams.page) || 1, 1); - const limit = Math.min( - Math.max(Number(queryParams.limit) || 20, 10), - 100, - ); - const offset = (page - 1) * limit; - - query += ` LIMIT $${paramIndex++} OFFSET $${paramIndex++};`; - values.push(limit, offset); - - const res = await tx.query(query, values); - - await tx.commit(); - - return reply.status(200).send( - app.buildResponse( - 200, - `Successfully searched records from the ${tableName} table`, - { - data: res.rows || [], - pagination: { - page, - limit, - total, - totalPages: Math.ceil(total / limit), - }, - }, - res, - ), - ); - } catch (err) { - if (tx) await tx.rollback().catch(() => {}); - throw err; - } finally { - tx?.release(); - } - }, + const baseIdentifier = buildApiIdentifier( + 'model', + defaultVariant, + modelName, + fieldName, + 'search', ); + const additionalVariants = getAdditionalVariants(config, baseIdentifier); + + for (const variant of additionalVariants) { + const variantApiIdentifier = buildApiIdentifier( + 'model', + variant, + modelName, + fieldName, + 'search', + ); + + if (config.apis?.[variantApiIdentifier]?.enabled === false) continue; + + const variantAuthorization = + config.apis?.[variantApiIdentifier]?.authorization ?? + config.authentication?.enabled ?? + false; + + registerSearchEndpoint( + app, + config, + modelName, + fieldName, + field, + model, + variant, + variantApiIdentifier, + variantAuthorization, + ); + } } } } +function registerSearchEndpoint( + app: FastifyInstance, + config: AppConfig, + modelName: string, + fieldName: string, + field: ModelFieldConfig, + model: ModelConfig, + variant: string, + apiIdentifier: string, + authorization: boolean, +): void { + const schema: Record = generateSchema( + fieldName, + field, + model, + modelName, + config, + authorization, + ); + + const path = `/${variant}/${modelName}/search/${fieldName}`; + + app.get( + path, + { + schema, + config: {apiIdentifier}, + preValidation: buildPreValidation(app, config, authorization), + preHandler: async request => { + await app.callWebhook('request', request, null); + }, + onSend: async (request, _, payload) => { + await app.callWebhook('response', request, payload); + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + const queryParams = request.query as Record; + const tableName = modelName; + + let query = `SELECT * FROM "${tableName}"`; + const values: unknown[] = []; + let paramIndex = 1; + + const whereClauses: string[] = []; + + const searchTerm = String(queryParams[`${fieldName}_search`] || ''); + whereClauses.push(`LOWER("${fieldName}") LIKE $${paramIndex++}`); + values.push(`%${searchTerm.toLowerCase()}%`); + + const { + whereClauses: filterClauses, + values: filterValues, + nextParamIndex, + } = applyFilters(queryParams, paramIndex, [`${fieldName}_search`]); + + whereClauses.push(...filterClauses); + values.push(...filterValues); + paramIndex = nextParamIndex; + + query += ` WHERE ${whereClauses.join(' AND ')}`; + + const countQuery = `SELECT COUNT(*) as total FROM "${tableName}" WHERE ${whereClauses.join(' AND ')}`; + + let tx; + try { + tx = await app.db.beginTransaction(); + + const countRes = await tx.query<{total: number | string}>( + countQuery, + values, + ); + const total = Number(countRes.rows[0]?.total || 0); + + if (queryParams.orderBy) { + query += ` ORDER BY "${queryParams.orderBy}" ${queryParams.orderDir === 'desc' ? 'DESC' : 'ASC'}`; + } + + const page = Math.max(Number(queryParams.page) || 1, 1); + const limit = Math.min( + Math.max(Number(queryParams.limit) || 20, 10), + 100, + ); + const offset = (page - 1) * limit; + + query += ` LIMIT $${paramIndex++} OFFSET $${paramIndex++};`; + values.push(limit, offset); + + const res = await tx.query(query, values); + + await tx.commit(); + + return reply.status(200).send( + app.buildResponse( + 200, + `Successfully searched records from the ${tableName} table`, + { + data: res.rows || [], + pagination: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }, + res, + ), + ); + } catch (err) { + if (tx) await tx.rollback().catch(() => {}); + throw err; + } finally { + tx?.release(); + } + }, + ); +} + function generateSchema( fieldName: string, field: ModelFieldConfig, diff --git a/src/server.ts b/src/server.ts index 3ca3dd3..839f3b7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -128,41 +128,46 @@ export async function startServer( await migrateDatabase(config); } - // register config-driven routes (models, aggregations, custom queries) - registerRoutes(app, config); + // register all config-driven routes under /api prefix (swagger excluded) + await app.register( + async app => { + registerRoutes(app, config); + + // register auth routes (only when up-auth is configured and enabled) + if ( + config.authentication?.enabled && + config.authentication?.provider?.type === 'up-auth' + ) { + const upConfig = config.authentication.provider + .config as UpAuthProviderConfig; + + registerRegistrationRoute(app, config); + registerLoginRoute(app, config); + registerChangePasswordRoute(app, config); + registerMeRoute(app, config); + registerDeleteMeRoute(app, config); + registerEditMeRoute(app, config); + + if (config.integrations?.email) { + registerForgotPasswordRoute(app, config); + registerForgotPasswordOtpVerifyRoute(app, config); + registerForgotPasswordResendOtpRoute(app, config); + } - // register auth routes (only when up-auth is configured and enabled) - if ( - config.authentication?.enabled && - config.authentication?.provider?.type === 'up-auth' - ) { - const upConfig = config.authentication.provider - .config as UpAuthProviderConfig; - - registerRegistrationRoute(app, config); - registerLoginRoute(app, config); - registerChangePasswordRoute(app, config); - registerMeRoute(app, config); - registerDeleteMeRoute(app, config); - registerEditMeRoute(app, config); - - if (config.integrations?.email) { - registerForgotPasswordRoute(app, config); - registerForgotPasswordOtpVerifyRoute(app, config); - registerForgotPasswordResendOtpRoute(app, config); - } - - if (upConfig.mfaRequired) { - registerLoginOtpVerifyRoute(app, config); - registerLoginResendOtpRoute(app, config); - } - - if (upConfig.userModel.isVerifiedField) { - registerRegistrationOtpVerifyRoute(app, config); - registerRegistrationResendOtpRoute(app, config); - registerEmailChangeRoute(app, config); - } - } + if (upConfig.mfaRequired) { + registerLoginOtpVerifyRoute(app, config); + registerLoginResendOtpRoute(app, config); + } + + if (upConfig.userModel.isVerifiedField) { + registerRegistrationOtpVerifyRoute(app, config); + registerRegistrationResendOtpRoute(app, config); + registerEmailChangeRoute(app, config); + } + } + }, + {prefix: '/api'}, + ); // Global error handler app.setErrorHandler( diff --git a/src/utils/config.ts b/src/utils/config.ts index 875bfba..4eec1d9 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -45,17 +45,83 @@ export function resolveEnvVars(config: T): T { return config; } +export function getVariantSegment(config: AppConfig): string { + return `.${config.application.dangerouslyOverrideDefaultVariant ?? 'v1'}`; +} + export function getAPIFromUniqueIdentifier( config: AppConfig, identifier: string, ): CustomEndpointConfig | null { const parts = identifier.split('.'); - if (parts[0] === 'customEndpoints') { - if (parts.length === 2) { - return config?.customEndpoints?.[parts[1]] ?? null; + if (parts[0] === 'custom') { + if (parts.length === 5) { + return config?.customEndpoints?.[parts[4]] ?? null; } } return null; } + +/** + * Parse an API identifier and extract its components. + * Format: .... + * Example: "aggregate.v1.users.id.getAggregation" + * @param identifier The API identifier string to parse + * @returns Object with module, variant, model, field, operation or null if invalid format + */ +export function parseApiIdentifier(identifier: string): { + module: string; + variant: string; + model: string; + field: string; + operation: string; +} | null { + const parts = identifier.split('.'); + if (parts.length !== 5) return null; + + return { + module: parts[0], + variant: parts[1], + model: parts[2], + field: parts[3], + operation: parts[4], + }; +} + +/** + * Get additional variants for a given API identifier. + * Looks up config.apiVariants and returns array of variant names. + * @param config The application configuration + * @param baseIdentifier The base identifier (with 'default' as variant placeholder) + * @returns Array of additional variant names, empty array if none found + */ +export function getAdditionalVariants( + config: AppConfig, + baseIdentifier: string, +): string[] { + const variantEntry = config.apiVariants?.[baseIdentifier]; + return variantEntry?.variants ?? []; +} + +/** + * Build an API identifier string with specific variant. + * Example: buildApiIdentifier('aggregate', 'admin', 'users', 'id', 'getAggregation') + * Returns: "aggregate.admin.users.id.getAggregation" + * @param module The module name (e.g., 'aggregate', 'model', 'auth') + * @param variant The variant name (e.g., 'v1', 'admin') + * @param model The model name (e.g., 'users', 'posts') + * @param field The field name (e.g., 'id', 'email') or 'unknown' or 'all' + * @param operation The operation name (e.g., 'getAggregation', 'search') + * @returns The constructed API identifier string + */ +export function buildApiIdentifier( + module: string, + variant: string, + model: string, + field: string, + operation: string, +): string { + return `${module}.${variant}.${model}.${field}.${operation}`; +} diff --git a/src/validators/config/schema.ts b/src/validators/config/schema.ts index 775f68a..65f2a21 100644 --- a/src/validators/config/schema.ts +++ b/src/validators/config/schema.ts @@ -58,6 +58,12 @@ const applicationSchema = { }, }, }, + dangerouslyOverrideDefaultVariant: { + type: 'string', + minLength: 1, + maxLength: 25, + pattern: '^[a-zA-Z0-9_-]+$', + }, }, }; @@ -552,6 +558,31 @@ const integrationsSchema = { }, }; +const apiVariantsSchema = { + type: 'object', + patternProperties: { + '^[A-Za-z0-9-_.]+$': { + type: 'object', + required: ['variants'], + additionalProperties: false, + properties: { + variants: { + type: 'array', + minItems: 1, + items: { + type: 'string', + minLength: 1, + maxLength: 25, + pattern: '^[a-zA-Z0-9_-]+$', + }, + uniqueItems: true, + }, + }, + }, + }, + additionalProperties: false, +}; + const schema = { type: 'object', required: ['application', 'docs', 'infrastructure', 'data'], @@ -576,6 +607,7 @@ const schema = { customEndpoints: customEndpointsSchema, authentication: authenticationSchema, integrations: integrationsSchema, + apiVariants: apiVariantsSchema, }, }; diff --git a/src/validators/config/validate-apis.ts b/src/validators/config/validate-apis.ts index 3951166..2e82a47 100644 --- a/src/validators/config/validate-apis.ts +++ b/src/validators/config/validate-apis.ts @@ -34,8 +34,8 @@ function validateApisConstraints(config: AppConfig): string[] { for (const key of keys) { const parts = key.split('.'); - if (parts[0] === 'customEndpoints') { - if (parts.length === 2) { + if (parts[0] === 'custom') { + if (parts.length === 5) { const endpointConfig = getAPIFromUniqueIdentifier(config, key); if (!endpointConfig) { @@ -46,7 +46,7 @@ function validateApisConstraints(config: AppConfig): string[] { errors.push(`apis/${key}: invalid key format`); continue; } - } else if (parts.length !== 4) { + } else if (parts.length !== 5) { errors.push(`apis/${key}: invalid key format`); continue; } diff --git a/tests/helpers/test-app.ts b/tests/helpers/test-app.ts index 8a9717b..55335cf 100644 --- a/tests/helpers/test-app.ts +++ b/tests/helpers/test-app.ts @@ -44,6 +44,7 @@ export async function createTestApp( apis?: ApisConfig, customEndpoints?: Record, authentication?: AuthenticationConfig, + apiVariants?: Record, ): Promise { const appConfig: AppConfig = { application: {name: 'Test App', logLevel: 'error'}, @@ -59,6 +60,7 @@ export async function createTestApp( apis, customEndpoints, authentication, + apiVariants, }; const fastify = Fastify(); diff --git a/tests/routes/aggregate.test.ts b/tests/routes/aggregate.test.ts index 1104ef8..77a7f20 100644 --- a/tests/routes/aggregate.test.ts +++ b/tests/routes/aggregate.test.ts @@ -56,7 +56,7 @@ describe('test aggregate api', () => { const response = await fastify.inject({ method: 'GET', - url: '/sales/aggregation/amount?operations=avg,max,min,sum,count', + url: '/v1/sales/aggregation/amount?operations=avg,max,min,sum,count', }); expect(response.statusCode).toBe(200); @@ -75,7 +75,7 @@ describe('test aggregate api', () => { await fastify.inject({ method: 'GET', - url: '/sales/aggregation/amount?operations=max,min', + url: '/v1/sales/aggregation/amount?operations=max,min', }); expect(pgClientQueryMock).toHaveBeenCalledTimes(3); @@ -103,7 +103,7 @@ describe('test aggregate api', () => { const response = await fastify.inject({ method: 'GET', - url: '/sales/aggregation/category?operations=frequency', + url: '/v1/sales/aggregation/category?operations=frequency', }); expect(response.statusCode).toBe(200); @@ -120,7 +120,7 @@ describe('test aggregate api', () => { await fastify.inject({ method: 'GET', - url: '/sales/aggregation/category?operations=frequency', + url: '/v1/sales/aggregation/category?operations=frequency', }); expect(pgClientQueryMock).toHaveBeenCalledTimes(3); @@ -163,7 +163,7 @@ describe('test aggregate api', () => { const response = await fastify.inject({ method: 'GET', - url: '/stats/aggregation/score?operations=avg,frequency', + url: '/v1/stats/aggregation/score?operations=avg,frequency', }); expect(response.statusCode).toBe(200); @@ -182,7 +182,7 @@ describe('test aggregate api', () => { const response = await fastify.inject({ method: 'GET', - url: '/sales/aggregation/amount', // missing query param + url: '/v1/sales/aggregation/amount', // missing query param }); expect(response.statusCode).toBe(400); @@ -195,7 +195,7 @@ describe('test aggregate api', () => { const response = await fastify.inject({ method: 'GET', - url: '/sales/aggregation/amount?operations=', + url: '/v1/sales/aggregation/amount?operations=', }); expect(response.statusCode).toBe(400); @@ -211,7 +211,7 @@ describe('test aggregate api', () => { const response = await fastify.inject({ method: 'GET', - url: '/sales/aggregation/amount?operations=sum,frequency', + url: '/v1/sales/aggregation/amount?operations=sum,frequency', }); expect(response.statusCode).toBe(400); @@ -229,7 +229,7 @@ describe('test aggregate api', () => { const response = await fastify.inject({ method: 'GET', - url: '/sales/aggregation/date?operations=count', + url: '/v1/sales/aggregation/date?operations=count', }); expect(response.statusCode).toBe(404); @@ -242,7 +242,7 @@ describe('test aggregate api', () => { const response = await fastify.inject({ method: 'GET', - url: '/nonexistent/aggregation/id?operations=count', + url: '/v1/nonexistent/aggregation/id?operations=count', }); expect(response.statusCode).toBe(404); @@ -252,7 +252,7 @@ describe('test aggregate api', () => { test('should return 404 when the API is disabled in config', async () => { const disabledApiConfig = { - 'aggregate.sales.amount.getAggregation': {enabled: false}, + 'aggregate.v1.sales.amount.getAggregation': {enabled: false}, }; const fastify = await createTestApp( @@ -263,7 +263,7 @@ describe('test aggregate api', () => { const response = await fastify.inject({ method: 'GET', - url: '/sales/aggregation/amount?operations=count', + url: '/v1/sales/aggregation/amount?operations=count', }); expect(response.statusCode).toBe(404); @@ -281,7 +281,7 @@ describe('test aggregate api', () => { const response = await fastify.inject({ method: 'GET', - url: '/sales/aggregation/amount?operations=sum', + url: '/v1/sales/aggregation/amount?operations=sum', }); expect(response.statusCode).toBe(200); @@ -302,7 +302,7 @@ describe('test aggregate api', () => { const response = await fastify.inject({ method: 'GET', - url: '/sales/aggregation/amount?operations=sum', + url: '/v1/sales/aggregation/amount?operations=sum', }); expect(response.statusCode).toBe(500); @@ -320,7 +320,7 @@ describe('test aggregate api', () => { const response = await fastify.inject({ method: 'GET', - url: '/sales/aggregation/amount?operations=sum', + url: '/v1/sales/aggregation/amount?operations=sum', }); expect(response.statusCode).toBe(500); @@ -335,7 +335,7 @@ describe('test aggregate api', () => { const response = await fastify.inject({ method: 'GET', - url: '/sales/aggregation/amount?operations=sum', + url: '/v1/sales/aggregation/amount?operations=sum', }); expect(response.statusCode).toBe(500); @@ -346,7 +346,7 @@ describe('test aggregate api', () => { describe('authentication', () => { const apisConfig = { - 'aggregate.sales.amount.getAggregation': { + 'aggregate.v1.sales.amount.getAggregation': { authorization: true, }, }; @@ -362,7 +362,7 @@ describe('test aggregate api', () => { const response = await fastify.inject({ method: 'GET', - url: '/sales/aggregation/amount?operations=count', + url: '/v1/sales/aggregation/amount?operations=count', }); expect(response.statusCode).toBe(401); @@ -387,7 +387,7 @@ describe('test aggregate api', () => { const response = await fastify.inject({ method: 'GET', - url: '/sales/aggregation/amount?operations=count', + url: '/v1/sales/aggregation/amount?operations=count', headers: { authorization: `Bearer ${token}`, }, @@ -398,4 +398,270 @@ describe('test aggregate api', () => { await fastify.close(); }); }); + + describe('API variants', () => { + test('should register additional variant endpoint when apiVariants is configured', async () => { + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({rows: [{count: 5}]}) // aggregation query + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + + const fastify = await createTestApp( + pgConfig, + aggregateModel, + undefined, + undefined, + undefined, + { + 'aggregate.v1.sales.amount.getAggregation': { + variants: ['admin'], + }, + }, + ); + + // Test that the admin variant endpoint is accessible + const response = await fastify.inject({ + method: 'GET', + url: '/admin/sales/aggregation/amount?operations=count', + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.count).toBe(5); + + await fastify.close(); + }); + + test('should register both default and variant endpoints', async () => { + const fastify = await createTestApp( + pgConfig, + aggregateModel, + undefined, + undefined, + undefined, + { + 'aggregate.v1.sales.amount.getAggregation': { + variants: ['admin'], + }, + }, + ); + + // Test default endpoint (with v1 prefix) + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({rows: [{sum: 100}]}) // aggregation query + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + + const defaultResponse = await fastify.inject({ + method: 'GET', + url: '/v1/sales/aggregation/amount?operations=sum', + }); + + expect(defaultResponse.statusCode).toBe(200); + expect(defaultResponse.json().data.sum).toBe(100); + + // Test variant endpoint + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({rows: [{sum: 200}]}) // aggregation query + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + + const variantResponse = await fastify.inject({ + method: 'GET', + url: '/admin/sales/aggregation/amount?operations=sum', + }); + + expect(variantResponse.statusCode).toBe(200); + expect(variantResponse.json().data.sum).toBe(200); + + await fastify.close(); + }); + + test('should register multiple variant endpoints', async () => { + const fastify = await createTestApp( + pgConfig, + aggregateModel, + undefined, + undefined, + undefined, + { + 'aggregate.v1.sales.amount.getAggregation': { + variants: ['admin', 'public'], + }, + }, + ); + + // Test admin variant + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({rows: [{avg: 75}]}) // aggregation query + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + + const adminResponse = await fastify.inject({ + method: 'GET', + url: '/admin/sales/aggregation/amount?operations=avg', + }); + + expect(adminResponse.statusCode).toBe(200); + expect(adminResponse.json().data.avg).toBe(75); + + // Test public variant + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({rows: [{avg: 65}]}) // aggregation query + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + + const publicResponse = await fastify.inject({ + method: 'GET', + url: '/public/sales/aggregation/amount?operations=avg', + }); + + expect(publicResponse.statusCode).toBe(200); + expect(publicResponse.json().data.avg).toBe(65); + + await fastify.close(); + }); + + test('should not register variant endpoint when disabled in apis config', async () => { + const fastify = await createTestApp( + pgConfig, + aggregateModel, + { + 'aggregate.admin.sales.amount.getAggregation': { + enabled: false, + }, + }, + undefined, + undefined, + { + 'aggregate.v1.sales.amount.getAggregation': { + variants: ['admin'], + }, + }, + ); + + const response = await fastify.inject({ + method: 'GET', + url: '/admin/sales/aggregation/amount?operations=count', + }); + + expect(response.statusCode).toBe(404); + + await fastify.close(); + }); + + test('should respect variant-specific authorization settings', async () => { + const fastify = await createTestApp( + pgConfig, + aggregateModel, + { + 'aggregate.v1.sales.amount.getAggregation': { + authorization: false, + }, + 'aggregate.admin.sales.amount.getAggregation': { + authorization: true, + }, + }, + undefined, + upAuthConfig, + { + 'aggregate.v1.sales.amount.getAggregation': { + variants: ['admin'], + }, + }, + ); + + // Default endpoint should work without auth + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({rows: [{count: 10}]}) // aggregation query + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + + const defaultResponse = await fastify.inject({ + method: 'GET', + url: '/v1/sales/aggregation/amount?operations=count', + }); + + expect(defaultResponse.statusCode).toBe(200); + + // Admin variant should require auth + const variantResponseNoAuth = await fastify.inject({ + method: 'GET', + url: '/admin/sales/aggregation/amount?operations=count', + }); + + expect(variantResponseNoAuth.statusCode).toBe(401); + + // Admin variant with valid token should work + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({rows: [{count: 15}]}) // aggregation query + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + + const token = fastify.jwt.sign({id: 1, email: 'admin@example.com'}); + + const variantResponseWithAuth = await fastify.inject({ + method: 'GET', + url: '/admin/sales/aggregation/amount?operations=count', + headers: { + authorization: `Bearer ${token}`, + }, + }); + + expect(variantResponseWithAuth.statusCode).toBe(200); + expect(variantResponseWithAuth.json().data.count).toBe(15); + + await fastify.close(); + }); + + test('should work with frequency aggregation on variant endpoints', async () => { + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({ + rows: [ + {val: 'electronics', c: '8'}, + {val: 'furniture', c: '3'}, + ], + }) // frequency query + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + + const fastify = await createTestApp( + pgConfig, + aggregateModel, + undefined, + undefined, + undefined, + { + 'aggregate.v1.sales.category.getAggregation': { + variants: ['admin'], + }, + }, + ); + + const response = await fastify.inject({ + method: 'GET', + url: '/admin/sales/aggregation/category?operations=frequency', + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.frequency).toEqual({ + electronics: 8, + furniture: 3, + }); + + await fastify.close(); + }); + + test('should not register variant when apiVariants config is empty', async () => { + const fastify = await createTestApp(pgConfig, aggregateModel); + + const response = await fastify.inject({ + method: 'GET', + url: '/admin/sales/aggregation/amount?operations=count', + }); + + expect(response.statusCode).toBe(404); + + await fastify.close(); + }); + }); }); diff --git a/tests/routes/change-email.test.ts b/tests/routes/change-email.test.ts index 55ee6d3..b7ae8d8 100644 --- a/tests/routes/change-email.test.ts +++ b/tests/routes/change-email.test.ts @@ -71,6 +71,7 @@ async function createChangeEmailApp( models: Record = authModels, dbConfig: DatabaseConfig = pgConfig, apis?: Record, + apiVariants?: Record, ): Promise { const app = Fastify(); const config: AppConfig = { @@ -86,6 +87,7 @@ async function createChangeEmailApp( data: {models}, authentication, ...(apis ? {apis} : {}), + ...(apiVariants ? {apiVariants} : {}), }; app.appConfig = config; @@ -141,7 +143,7 @@ describe('PATCH /auth/user/email', () => { const response = await app.inject({ method: 'PATCH', - url: '/auth/user/email', + url: '/v1/auth/user/email', payload: {email: 'new@example.com'}, }); @@ -154,7 +156,7 @@ describe('PATCH /auth/user/email', () => { const response = await app.inject({ method: 'PATCH', - url: '/auth/user/email', + url: '/v1/auth/user/email', payload: {email: 'new@example.com'}, }); @@ -169,13 +171,13 @@ describe('PATCH /auth/user/email', () => { authModels, pgConfig, { - 'auth.users.all.emailChange': {enabled: false}, + 'auth.v1.users.unknown.emailChange': {enabled: false}, }, ); const response = await app.inject({ method: 'PATCH', - url: '/auth/user/email', + url: '/v1/auth/user/email', payload: {email: 'new@example.com'}, }); @@ -191,7 +193,7 @@ describe('PATCH /auth/user/email', () => { const response = await app.inject({ method: 'PATCH', - url: '/auth/user/email', + url: '/v1/auth/user/email', payload: {email: 'new@example.com'}, }); @@ -207,7 +209,7 @@ describe('PATCH /auth/user/email', () => { const response = await app.inject({ method: 'PATCH', - url: '/auth/user/email', + url: '/v1/auth/user/email', headers: { authorization: 'Bearer invalidtoken', }, @@ -228,7 +230,7 @@ describe('PATCH /auth/user/email', () => { const response = await app.inject({ method: 'PATCH', - url: '/auth/user/email', + url: '/v1/auth/user/email', headers: { authorization: `Bearer ${token}`, }, @@ -269,7 +271,7 @@ describe('PATCH /auth/user/email', () => { const response = await app.inject({ method: 'PATCH', - url: '/auth/user/email', + url: '/v1/auth/user/email', headers: { authorization: `Bearer ${token}`, }, @@ -341,7 +343,7 @@ describe('PATCH /auth/user/email', () => { const response = await app.inject({ method: 'PATCH', - url: '/auth/user/email', + url: '/v1/auth/user/email', headers: { authorization: `Bearer ${token}`, }, @@ -377,7 +379,7 @@ describe('PATCH /auth/user/email', () => { const response = await app.inject({ method: 'PATCH', - url: '/auth/user/email', + url: '/v1/auth/user/email', headers: { authorization: `Bearer ${token}`, }, @@ -401,7 +403,7 @@ describe('PATCH /auth/user/email', () => { const response = await app.inject({ method: 'PATCH', - url: '/auth/user/email', + url: '/v1/auth/user/email', headers: {authorization: `Bearer ${token}`}, payload: {email: 'new@example.com'}, }); @@ -419,7 +421,7 @@ describe('PATCH /auth/user/email', () => { const response = await app.inject({ method: 'PATCH', - url: '/auth/user/email', + url: '/v1/auth/user/email', headers: {authorization: `Bearer ${token}`}, payload: {email: 'new@example.com'}, }); @@ -428,4 +430,54 @@ describe('PATCH /auth/user/email', () => { await app.close(); }); }); + + describe('API variants', () => { + test('should register additional variant endpoint when apiVariants is configured', async () => { + const app = await createChangeEmailApp( + upAuthConfig, + undefined, + undefined, + undefined, + { + 'auth.v1.users.unknown.emailChange': { + variants: ['admin'], + }, + }, + ); + const token = app.jwt.sign({id: 1, email: 'admin@example.com'}); + vi.spyOn(bcrypt, 'compare').mockResolvedValue(true as never); + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({ + rows: [{id: 1, email: 'admin@example.com', password: 'hashed'}], + rowCount: 1, + }) // SELECT + .mockResolvedValueOnce({rows: [], rowCount: 1}) // UPDATE + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + const response = await app.inject({ + method: 'PATCH', + url: '/admin/auth/user/email', + headers: {authorization: `Bearer ${token}`}, + payload: {email: 'new@example.com'}, + }); + expect(response.statusCode).toBe(200); + await app.close(); + }); + + test('should not register variant endpoint when disabled in apis config', async () => { + const app = await createChangeEmailApp( + upAuthConfig, + undefined, + undefined, + {'auth.admin.users.unknown.emailChange': {enabled: false}}, + {'auth.v1.users.unknown.emailChange': {variants: ['admin']}}, + ); + const response = await app.inject({ + method: 'PATCH', + url: '/admin/auth/user/email', + }); + expect(response.statusCode).toBe(404); + await app.close(); + }); + }); }); diff --git a/tests/routes/change-password.test.ts b/tests/routes/change-password.test.ts index da526a0..9e41f78 100644 --- a/tests/routes/change-password.test.ts +++ b/tests/routes/change-password.test.ts @@ -62,6 +62,7 @@ async function createAuthApp( models: Record = authModels, dbConfig: DatabaseConfig = pgConfig, apis?: Record, + apiVariants?: Record, ): Promise { const app = Fastify(); const config: AppConfig = { @@ -77,6 +78,7 @@ async function createAuthApp( data: {models}, authentication, ...(apis ? {apis} : {}), + ...(apiVariants ? {apiVariants} : {}), }; app.appConfig = config; await app.register(databasePlugin); @@ -111,7 +113,7 @@ describe('POST /auth/change-password', () => { const response = await app.inject({ method: 'POST', - url: '/auth/change-password', + url: '/v1/auth/change-password', payload: {existingPassword: 'old', newPassword: 'new'}, }); @@ -124,7 +126,7 @@ describe('POST /auth/change-password', () => { const response = await app.inject({ method: 'POST', - url: '/auth/change-password', + url: '/v1/auth/change-password', payload: {existingPassword: 'old', newPassword: 'new'}, }); @@ -135,12 +137,12 @@ describe('POST /auth/change-password', () => { test('should NOT register the route when the API is disabled via apis config', async () => { const app = await createAuthApp(upAuthConfig, authModels, pgConfig, { - 'auth.users.all.changePassword': {enabled: false}, + 'auth.v1.users.unknown.changePassword': {enabled: false}, }); const response = await app.inject({ method: 'POST', - url: '/auth/change-password', + url: '/v1/auth/change-password', payload: {existingPassword: 'old', newPassword: 'new'}, }); @@ -181,7 +183,7 @@ describe('POST /auth/change-password', () => { const response = await app.inject({ method: 'POST', - url: '/auth/change-password', + url: '/v1/auth/change-password', headers: { authorization: `Bearer ${token}`, }, @@ -223,7 +225,7 @@ describe('POST /auth/change-password', () => { const response = await app.inject({ method: 'POST', - url: '/auth/change-password', + url: '/v1/auth/change-password', payload: {existingPassword: 'old', newPassword: 'new'}, }); @@ -239,7 +241,7 @@ describe('POST /auth/change-password', () => { const response = await app.inject({ method: 'POST', - url: '/auth/change-password', + url: '/v1/auth/change-password', headers: { authorization: 'Bearer invalidtoken', }, @@ -261,7 +263,7 @@ describe('POST /auth/change-password', () => { const response = await app.inject({ method: 'POST', - url: '/auth/change-password', + url: '/v1/auth/change-password', headers: { authorization: `Bearer ${token}`, }, @@ -284,7 +286,7 @@ describe('POST /auth/change-password', () => { const response = await app.inject({ method: 'POST', - url: '/auth/change-password', + url: '/v1/auth/change-password', headers: { authorization: `Bearer ${token}`, }, @@ -307,7 +309,7 @@ describe('POST /auth/change-password', () => { const response = await app.inject({ method: 'POST', - url: '/auth/change-password', + url: '/v1/auth/change-password', headers: {authorization: `Bearer ${token}`}, payload: {existingPassword: 'old', newPassword: 'new'}, }); @@ -336,7 +338,7 @@ describe('POST /auth/change-password', () => { const response = await app.inject({ method: 'POST', - url: '/auth/change-password', + url: '/v1/auth/change-password', headers: { authorization: `Bearer ${token}`, }, @@ -356,7 +358,7 @@ describe('POST /auth/change-password', () => { const response = await app.inject({ method: 'POST', - url: '/auth/change-password', + url: '/v1/auth/change-password', headers: { authorization: `Bearer ${token}`, }, @@ -367,4 +369,55 @@ describe('POST /auth/change-password', () => { await app.close(); }); }); + + describe('API variants', () => { + test('should register additional variant endpoint when apiVariants is configured', async () => { + const app = await createAuthApp( + upAuthConfig, + undefined, + undefined, + undefined, + { + 'auth.v1.users.unknown.changePassword': { + variants: ['admin'], + }, + }, + ); + const token = app.jwt.sign({id: 1, email: 'admin@example.com'}); + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({ + rows: [{id: 1, email: 'admin@example.com', password: 'hashed'}], + rowCount: 1, + }) // SELECT + .mockResolvedValueOnce({rows: [], rowCount: 1}) // UPDATE + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + vi.spyOn(bcrypt, 'compare').mockResolvedValue(true as never); + vi.spyOn(bcrypt, 'hash').mockResolvedValue('new_hashed' as never); + const response = await app.inject({ + method: 'POST', + url: '/admin/auth/change-password', + headers: {authorization: `Bearer ${token}`}, + payload: {existingPassword: 'old', newPassword: 'new'}, + }); + expect(response.statusCode).toBe(200); + await app.close(); + }); + + test('should not register variant endpoint when disabled in apis config', async () => { + const app = await createAuthApp( + upAuthConfig, + undefined, + undefined, + {'auth.admin.users.unknown.changePassword': {enabled: false}}, + {'auth.v1.users.unknown.changePassword': {variants: ['admin']}}, + ); + const response = await app.inject({ + method: 'POST', + url: '/admin/auth/change-password', + }); + expect(response.statusCode).toBe(404); + await app.close(); + }); + }); }); diff --git a/tests/routes/custom-endpoints.test.ts b/tests/routes/custom-endpoints.test.ts index 8a8d414..4a4e623 100644 --- a/tests/routes/custom-endpoints.test.ts +++ b/tests/routes/custom-endpoints.test.ts @@ -68,7 +68,7 @@ describe('test custom-endpoints api', () => { // Successfully call the endpoint with correct schema const validResponse = await fastify.inject({ method: 'GET', - url: '/custom-endpoints/search-users', + url: '/v1/custom-endpoints/search-users', query: { status: 'active', minAge: '18', @@ -96,7 +96,7 @@ describe('test custom-endpoints api', () => { // Since it's a POST with path variables, we use the injected /:id const validResponse = await fastify.inject({ method: 'POST', - url: '/custom-endpoints/update-user/42', + url: '/v1/custom-endpoints/update-user/42', payload: { name: 'Jane Doe', }, @@ -133,7 +133,7 @@ describe('test custom-endpoints api', () => { const res = await fastify.inject({ method: 'POST', - url: '/custom-endpoints/all-types', + url: '/v1/custom-endpoints/all-types', payload: { b: true, t: 'some long text', @@ -160,7 +160,7 @@ describe('test custom-endpoints api', () => { // minAge is expected to be integer. If we pass a string that isn't parseable as int, fastify fails const invalidResponse = await fastify.inject({ method: 'GET', - url: '/custom-endpoints/search-users', + url: '/v1/custom-endpoints/search-users', query: { status: 'active', minAge: 'invalid-string', @@ -182,7 +182,7 @@ describe('test custom-endpoints api', () => { const invalidBodyResponse = await fastify.inject({ method: 'POST', - url: '/custom-endpoints/update-user/42', + url: '/v1/custom-endpoints/update-user/42', payload: { name: 'Jane Doe', extra_field: 'not allowed', @@ -205,7 +205,7 @@ describe('test custom-endpoints api', () => { const invalidPathResponse = await fastify.inject({ method: 'POST', - url: '/custom-endpoints/update-user/not-a-number', + url: '/v1/custom-endpoints/update-user/not-a-number', payload: { name: 'Jane Doe', }, @@ -240,7 +240,7 @@ describe('test custom-endpoints api', () => { const res = await fastify.inject({ method: 'POST', - url: '/custom-endpoints/missing-param', + url: '/v1/custom-endpoints/missing-param', // Omitting 'status' query string }); @@ -271,7 +271,7 @@ describe('test custom-endpoints api', () => { const res = await fastify.inject({ method: 'POST', - url: '/custom-endpoints/missing-body', + url: '/v1/custom-endpoints/missing-body', payload: { // 'id' is missing }, @@ -288,7 +288,7 @@ describe('test custom-endpoints api', () => { describe('authentication', () => { const apisConfig = { - 'customEndpoints.searchUsers': { + 'custom.v1.all.unknown.searchUsers': { authorization: true, }, }; @@ -304,7 +304,7 @@ describe('test custom-endpoints api', () => { const response = await fastify.inject({ method: 'GET', - url: '/custom-endpoints/search-users', + url: '/v1/custom-endpoints/search-users', query: {status: 'active', minAge: '18'}, }); @@ -325,7 +325,7 @@ describe('test custom-endpoints api', () => { const response = await fastify.inject({ method: 'GET', - url: '/custom-endpoints/search-users', + url: '/v1/custom-endpoints/search-users', headers: { authorization: `Bearer ${token}`, }, @@ -355,7 +355,7 @@ describe('test custom-endpoints api', () => { const response = await fastify.inject({ method: 'GET', - url: '/custom-endpoints/search-users', + url: '/v1/custom-endpoints/search-users', headers: { 'x-api-key': 'test-key-123', }, @@ -391,7 +391,7 @@ describe('test custom-endpoints api', () => { const response = await fastify.inject({ method: 'GET', - url: '/custom-endpoints/unsupported', + url: '/v1/custom-endpoints/unsupported', }); expect(response.statusCode).toBe(500); @@ -427,7 +427,7 @@ describe('test custom-endpoints api', () => { const response = await fastify.inject({ method: 'GET', - url: '/custom-endpoints/mismatched', + url: '/v1/custom-endpoints/mismatched', }); // It should still register but without the parameters @@ -459,7 +459,7 @@ describe('test custom-endpoints api', () => { const response = await fastify.inject({ method: 'GET', - url: '/custom-endpoints/unknown-type', + url: '/v1/custom-endpoints/unknown-type', query: {name: 'Alice'}, }); @@ -471,7 +471,7 @@ describe('test custom-endpoints api', () => { describe('api config disabled', () => { test('should skip endpoint when enabled is false', async () => { const apisConfig = { - 'customEndpoints.searchUsers': { + 'custom.v1.all.unknown.searchUsers': { enabled: false, }, }; @@ -485,7 +485,7 @@ describe('test custom-endpoints api', () => { const res = await fastify.inject({ method: 'GET', - url: '/custom-endpoints/search-users', + url: '/v1/custom-endpoints/search-users', query: {status: 'active', minAge: '18'}, }); @@ -519,7 +519,7 @@ describe('test custom-endpoints api', () => { // Path param 'id' is required by default, query params are optional const res = await fastify.inject({ method: 'GET', - url: '/custom-endpoints/get-user/10', + url: '/v1/custom-endpoints/get-user/10', }); expect(res.statusCode).toBe(200); @@ -537,7 +537,7 @@ describe('test custom-endpoints api', () => { // searchUsers has validation requiring minAge >= 1 const res = await fastify.inject({ method: 'GET', - url: '/custom-endpoints/search-users', + url: '/v1/custom-endpoints/search-users', query: { status: 'active', minAge: '0', // violates minimum: 1 @@ -562,7 +562,7 @@ describe('test custom-endpoints api', () => { // searchUsers requires minAge in validation const res = await fastify.inject({ method: 'GET', - url: '/custom-endpoints/search-users', + url: '/v1/custom-endpoints/search-users', query: {}, }); @@ -573,4 +573,42 @@ describe('test custom-endpoints api', () => { await fastify.close(); }); }); + + describe('API variants', () => { + test('should register additional variant endpoint when apiVariants is configured', async () => { + const fastify = await createTestApp( + pgConfig, + {}, + undefined, + customEndpoints, + undefined, + {'custom.v1.all.unknown.searchUsers': {variants: ['admin']}}, + ); + const response = await fastify.inject({ + method: 'GET', + url: '/admin/custom-endpoints/search-users', + query: {status: 'active', minAge: '18'}, + }); + expect(response.statusCode).toBe(200); + await fastify.close(); + }); + + test('should not register variant endpoint when disabled in apis config', async () => { + const fastify = await createTestApp( + pgConfig, + {}, + {'custom.admin.all.unknown.searchUsers': {enabled: false}}, + customEndpoints, + undefined, + {'custom.v1.all.unknown.searchUsers': {variants: ['admin']}}, + ); + const res = await fastify.inject({ + method: 'GET', + url: '/admin/custom-endpoints/search-users', + query: {status: 'active', minAge: '18'}, + }); + expect(res.statusCode).toBe(404); + await fastify.close(); + }); + }); }); diff --git a/tests/routes/delete-me.test.ts b/tests/routes/delete-me.test.ts index 872cd7a..d2f2db1 100644 --- a/tests/routes/delete-me.test.ts +++ b/tests/routes/delete-me.test.ts @@ -61,6 +61,7 @@ async function createDeleteMeApp( models: Record = authModels, dbConfig: DatabaseConfig = pgConfig, apis?: Record, + apiVariants?: Record, ): Promise { const app = Fastify(); const config: AppConfig = { @@ -76,6 +77,7 @@ async function createDeleteMeApp( data: {models}, authentication, ...(apis ? {apis} : {}), + ...(apiVariants ? {apiVariants} : {}), }; app.appConfig = config; await app.register(databasePlugin); @@ -110,7 +112,7 @@ describe('DELETE /auth/user/me', () => { const response = await app.inject({ method: 'DELETE', - url: '/auth/user/me', + url: '/v1/auth/user/me', }); expect(response.statusCode).toBe(404); @@ -122,7 +124,7 @@ describe('DELETE /auth/user/me', () => { const response = await app.inject({ method: 'DELETE', - url: '/auth/user/me', + url: '/v1/auth/user/me', }); expect(response.statusCode).toBe(404); @@ -132,12 +134,12 @@ describe('DELETE /auth/user/me', () => { test('should NOT register the route when the API is disabled via apis config', async () => { const app = await createDeleteMeApp(upAuthConfig, authModels, pgConfig, { - 'auth.users.all.deleteMe': {enabled: false}, + 'auth.v1.users.unknown.deleteMe': {enabled: false}, }); const response = await app.inject({ method: 'DELETE', - url: '/auth/user/me', + url: '/v1/auth/user/me', }); expect(response.statusCode).toBe(404); @@ -152,7 +154,7 @@ describe('DELETE /auth/user/me', () => { const response = await app.inject({ method: 'DELETE', - url: '/auth/user/me', + url: '/v1/auth/user/me', }); expect(response.statusCode).toBe(401); @@ -167,7 +169,7 @@ describe('DELETE /auth/user/me', () => { const response = await app.inject({ method: 'DELETE', - url: '/auth/user/me', + url: '/v1/auth/user/me', headers: { authorization: 'Bearer invalidtoken', }, @@ -187,7 +189,7 @@ describe('DELETE /auth/user/me', () => { const response = await app.inject({ method: 'DELETE', - url: '/auth/user/me', + url: '/v1/auth/user/me', headers: { authorization: `Bearer ${token}`, }, @@ -220,7 +222,7 @@ describe('DELETE /auth/user/me', () => { const response = await app.inject({ method: 'DELETE', - url: '/auth/user/me', + url: '/v1/auth/user/me', headers: { authorization: `Bearer ${token}`, }, @@ -259,7 +261,7 @@ describe('DELETE /auth/user/me', () => { const response = await app.inject({ method: 'DELETE', - url: '/auth/user/me', + url: '/v1/auth/user/me', headers: { authorization: `Bearer ${token}`, }, @@ -282,7 +284,7 @@ describe('DELETE /auth/user/me', () => { const response = await app.inject({ method: 'DELETE', - url: '/auth/user/me', + url: '/v1/auth/user/me', headers: {authorization: `Bearer ${token}`}, }); @@ -290,4 +292,52 @@ describe('DELETE /auth/user/me', () => { await app.close(); }); }); + + describe('API variants', () => { + test('should register additional variant endpoint when apiVariants is configured', async () => { + const app = await createDeleteMeApp( + upAuthConfig, + undefined, + undefined, + undefined, + { + 'auth.v1.users.unknown.deleteMe': { + variants: ['admin'], + }, + }, + ); + const token = app.jwt.sign({id: 1, email: 'admin@example.com'}); + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({ + rows: [{id: 1, email: 'admin@example.com'}], + rowCount: 1, + }) // SELECT + .mockResolvedValueOnce({rows: [], rowCount: 1}) // DELETE + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + const response = await app.inject({ + method: 'DELETE', + url: '/admin/auth/user/me', + headers: {authorization: `Bearer ${token}`}, + }); + expect(response.statusCode).toBe(200); + await app.close(); + }); + + test('should not register variant endpoint when disabled in apis config', async () => { + const app = await createDeleteMeApp( + upAuthConfig, + undefined, + undefined, + {'auth.admin.users.unknown.deleteMe': {enabled: false}}, + {'auth.v1.users.unknown.deleteMe': {variants: ['admin']}}, + ); + const response = await app.inject({ + method: 'DELETE', + url: '/admin/auth/user/me', + }); + expect(response.statusCode).toBe(404); + await app.close(); + }); + }); }); diff --git a/tests/routes/delete.test.ts b/tests/routes/delete.test.ts index 771335a..91c7c39 100644 --- a/tests/routes/delete.test.ts +++ b/tests/routes/delete.test.ts @@ -69,7 +69,7 @@ describe('test delete api', () => { const response = await fastify.inject({ method: 'DELETE', - url: '/users/id/42', + url: '/v1/users/id/42', }); expect(response.statusCode).toBe(204); @@ -94,7 +94,7 @@ describe('test delete api', () => { const response = await fastify.inject({ method: 'DELETE', - url: '/posts/slug/hello-world', + url: '/v1/posts/slug/hello-world', }); expect(response.statusCode).toBe(204); @@ -116,7 +116,7 @@ describe('test delete api', () => { // Delete by id const byId = await fastify.inject({ method: 'DELETE', - url: '/posts/id/10', + url: '/v1/posts/id/10', }); expect(byId.statusCode).toBe(204); expect(pgClientQueryMock).toHaveBeenCalledWith( @@ -129,7 +129,7 @@ describe('test delete api', () => { // Delete by slug const bySlug = await fastify.inject({ method: 'DELETE', - url: '/posts/slug/my-post', + url: '/v1/posts/slug/my-post', }); expect(bySlug.statusCode).toBe(204); expect(pgClientQueryMock).toHaveBeenCalledWith( @@ -144,12 +144,12 @@ describe('test delete api', () => { describe('edge cases', () => { test('should return 404 when the delete API is disabled via config', async () => { const fastify = await createTestApp(pgConfig, singleDeletableModel, { - 'model.users.id.delete': {enabled: false}, + 'model.v1.users.id.delete': {enabled: false}, }); const response = await fastify.inject({ method: 'DELETE', - url: '/users/id/42', + url: '/v1/users/id/42', }); expect(response.statusCode).toBe(404); @@ -162,7 +162,7 @@ describe('test delete api', () => { const response = await fastify.inject({ method: 'DELETE', - url: '/logs/id/1', + url: '/v1/logs/id/1', }); expect(response.statusCode).toBe(404); @@ -176,7 +176,7 @@ describe('test delete api', () => { const response = await fastify.inject({ method: 'DELETE', - url: '/nonexistent/field/1', + url: '/v1/nonexistent/field/1', }); expect(response.statusCode).toBe(404); @@ -196,7 +196,7 @@ describe('test delete api', () => { const response = await fastify.inject({ method: 'DELETE', - url: '/users/id/1', + url: '/v1/users/id/1', }); expect(response.statusCode).toBe(500); @@ -212,7 +212,7 @@ describe('test delete api', () => { const response = await fastify.inject({ method: 'DELETE', - url: '/users/id/1', + url: '/v1/users/id/1', }); expect(response.statusCode).toBe(500); @@ -222,7 +222,7 @@ describe('test delete api', () => { describe('authentication', () => { const apisConfig = { - 'model.users.id.delete': { + 'model.v1.users.id.delete': { enabled: true, authorization: true, }, @@ -239,7 +239,7 @@ describe('test delete api', () => { const response = await fastify.inject({ method: 'DELETE', - url: '/users/id/1', + url: '/v1/users/id/1', }); expect(response.statusCode).toBe(401); @@ -260,7 +260,7 @@ describe('test delete api', () => { const response = await fastify.inject({ method: 'DELETE', - url: '/users/id/1', + url: '/v1/users/id/1', headers: { authorization: 'Bearer invalid-token', }, @@ -283,7 +283,7 @@ describe('test delete api', () => { const response = await fastify.inject({ method: 'DELETE', - url: '/users/id/1', + url: '/v1/users/id/1', headers: { authorization: `Bearer ${token}`, }, @@ -312,7 +312,7 @@ describe('test delete api', () => { const response = await fastify.inject({ method: 'DELETE', - url: '/users/id/1', + url: '/v1/users/id/1', }); expect(response.statusCode).toBe(401); @@ -345,7 +345,7 @@ describe('test delete api', () => { const response = await fastify.inject({ method: 'DELETE', - url: '/users/id/1', + url: '/v1/users/id/1', }); // Should succeed because authentication is disabled, auth check is skipped @@ -353,4 +353,64 @@ describe('test delete api', () => { await fastify.close(); }); }); + + describe('API variants', () => { + test('should register additional variant endpoint when apiVariants is configured', async () => { + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({rows: [], rowCount: 1}) // DELETE + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + + const fastify = await createTestApp( + pgConfig, + singleDeletableModel, + undefined, + undefined, + undefined, + { + 'model.v1.users.id.delete': { + variants: ['admin'], + }, + }, + ); + + const response = await fastify.inject({ + method: 'DELETE', + url: '/admin/users/id/42', + }); + + expect(response.statusCode).toBe(204); + expect(response.body).toBe(''); + + await fastify.close(); + }); + + test('should not register variant endpoint when disabled in apis config', async () => { + const fastify = await createTestApp( + pgConfig, + singleDeletableModel, + { + 'model.admin.users.id.delete': { + enabled: false, + }, + }, + undefined, + undefined, + { + 'model.v1.users.id.delete': { + variants: ['admin'], + }, + }, + ); + + const response = await fastify.inject({ + method: 'DELETE', + url: '/admin/users/id/42', + }); + + expect(response.statusCode).toBe(404); + + await fastify.close(); + }); + }); }); diff --git a/tests/routes/edit-me.test.ts b/tests/routes/edit-me.test.ts index 4680eac..6f7a7c3 100644 --- a/tests/routes/edit-me.test.ts +++ b/tests/routes/edit-me.test.ts @@ -63,6 +63,7 @@ async function createEditMeApp( models: Record = authModels, dbConfig: DatabaseConfig = pgConfig, apis?: Record, + apiVariants?: Record, ): Promise { const app = Fastify(); const config: AppConfig = { @@ -78,6 +79,7 @@ async function createEditMeApp( data: {models}, authentication, ...(apis ? {apis} : {}), + ...(apiVariants ? {apiVariants} : {}), }; app.appConfig = config; await app.register(databasePlugin); @@ -112,7 +114,7 @@ describe('PATCH /auth/user/me', () => { const response = await app.inject({ method: 'PATCH', - url: '/auth/user/me', + url: '/v1/auth/user/me', payload: {name: 'New Name'}, }); @@ -125,7 +127,7 @@ describe('PATCH /auth/user/me', () => { const response = await app.inject({ method: 'PATCH', - url: '/auth/user/me', + url: '/v1/auth/user/me', payload: {name: 'New Name'}, }); @@ -136,12 +138,12 @@ describe('PATCH /auth/user/me', () => { test('should NOT register the route when the API is disabled via apis config', async () => { const app = await createEditMeApp(upAuthConfig, authModels, pgConfig, { - 'auth.users.all.editMe': {enabled: false}, + 'auth.v1.users.unknown.editMe': {enabled: false}, }); const response = await app.inject({ method: 'PATCH', - url: '/auth/user/me', + url: '/v1/auth/user/me', payload: {name: 'New Name'}, }); @@ -157,7 +159,7 @@ describe('PATCH /auth/user/me', () => { const response = await app.inject({ method: 'PATCH', - url: '/auth/user/me', + url: '/v1/auth/user/me', payload: {name: 'New Name'}, }); @@ -173,7 +175,7 @@ describe('PATCH /auth/user/me', () => { const response = await app.inject({ method: 'PATCH', - url: '/auth/user/me', + url: '/v1/auth/user/me', headers: { authorization: 'Bearer invalidtoken', }, @@ -194,7 +196,7 @@ describe('PATCH /auth/user/me', () => { const response = await app.inject({ method: 'PATCH', - url: '/auth/user/me', + url: '/v1/auth/user/me', headers: { authorization: `Bearer ${token}`, }, @@ -234,7 +236,7 @@ describe('PATCH /auth/user/me', () => { const response = await app.inject({ method: 'PATCH', - url: '/auth/user/me', + url: '/v1/auth/user/me', headers: { authorization: `Bearer ${token}`, }, @@ -271,7 +273,7 @@ describe('PATCH /auth/user/me', () => { const response = await app.inject({ method: 'PATCH', - url: '/auth/user/me', + url: '/v1/auth/user/me', headers: { authorization: `Bearer ${token}`, }, @@ -295,7 +297,7 @@ describe('PATCH /auth/user/me', () => { const response = await app.inject({ method: 'PATCH', - url: '/auth/user/me', + url: '/v1/auth/user/me', headers: { authorization: `Bearer ${token}`, }, @@ -319,7 +321,7 @@ describe('PATCH /auth/user/me', () => { const response = await app.inject({ method: 'PATCH', - url: '/auth/user/me', + url: '/v1/auth/user/me', headers: {authorization: `Bearer ${token}`}, payload: {name: 'Alice'}, }); @@ -328,4 +330,61 @@ describe('PATCH /auth/user/me', () => { await app.close(); }); }); + + describe('API variants', () => { + test('should register additional variant endpoint when apiVariants is configured', async () => { + const app = await createEditMeApp( + upAuthConfig, + undefined, + undefined, + undefined, + { + 'auth.v1.users.unknown.editMe': { + variants: ['admin'], + }, + }, + ); + const token = app.jwt.sign({id: 1, email: 'admin@example.com'}); + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({ + rows: [ + { + id: 1, + email: 'admin@example.com', + password: 'hashed', + name: 'Admin', + avatar: null, + }, + ], + rowCount: 1, + }) // SELECT + .mockResolvedValueOnce({rows: [], rowCount: 1}) // UPDATE + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + const response = await app.inject({ + method: 'PATCH', + url: '/admin/auth/user/me', + headers: {authorization: `Bearer ${token}`}, + payload: {name: 'New Name'}, + }); + expect(response.statusCode).toBe(200); + await app.close(); + }); + + test('should not register variant endpoint when disabled in apis config', async () => { + const app = await createEditMeApp( + upAuthConfig, + undefined, + undefined, + {'auth.admin.users.unknown.editMe': {enabled: false}}, + {'auth.v1.users.unknown.editMe': {variants: ['admin']}}, + ); + const response = await app.inject({ + method: 'PATCH', + url: '/admin/auth/user/me', + }); + expect(response.statusCode).toBe(404); + await app.close(); + }); + }); }); diff --git a/tests/routes/edit.test.ts b/tests/routes/edit.test.ts index 31f62dc..be92c9e 100644 --- a/tests/routes/edit.test.ts +++ b/tests/routes/edit.test.ts @@ -91,7 +91,7 @@ describe('test edit api', () => { const response = await fastify.inject({ method: 'PATCH', - url: '/users/id/1', + url: '/v1/users/id/1', payload: { name: 'Bob', }, @@ -111,7 +111,7 @@ describe('test edit api', () => { await fastify.inject({ method: 'PATCH', - url: '/users/id/5', + url: '/v1/users/id/5', payload: { email: 'bob@example.com', name: 'Bob', @@ -134,7 +134,7 @@ describe('test edit api', () => { const response = await fastify.inject({ method: 'PATCH', - url: '/users/id/1', + url: '/v1/users/id/1', payload: {}, }); @@ -148,7 +148,7 @@ describe('test edit api', () => { await fastify.inject({ method: 'PATCH', - url: '/users/id/1', + url: '/v1/users/id/1', payload: { id: 999, // User trying to edit the ID name: 'Alice', @@ -176,7 +176,7 @@ describe('test edit api', () => { const response = await fastify.inject({ method: 'PUT', - url: '/users/id/1', + url: '/v1/users/id/1', payload: { name: 'Charlie', email: 'charlie@example.com', @@ -193,7 +193,7 @@ describe('test edit api', () => { const response = await fastify.inject({ method: 'PUT', - url: '/users/id/1', + url: '/v1/users/id/1', payload: { name: 'Charlie', // email is missing, but required for PUT @@ -212,7 +212,7 @@ describe('test edit api', () => { const response = await fastify.inject({ method: 'PUT', - url: '/posts/id/1', + url: '/v1/posts/id/1', payload: { title: 'Title', // content is missing, validation requires both @@ -234,7 +234,7 @@ describe('test edit api', () => { const response = await fastify.inject({ method: 'PATCH', - url: '/posts/id/1', + url: '/v1/posts/id/1', payload: { title: 'Only Title', // content is missing, but PATCH removes required array }, @@ -273,7 +273,7 @@ describe('test edit api', () => { const response = await fastify.inject({ method: 'PUT', - url: '/tasks/id/1', + url: '/v1/tasks/id/1', payload: {status: 'in_progress'}, }); @@ -287,7 +287,7 @@ describe('test edit api', () => { const response = await fastify.inject({ method: 'PUT', - url: '/tasks/id/1', + url: '/v1/tasks/id/1', payload: {status: 'cancelled'}, }); @@ -307,7 +307,7 @@ describe('test edit api', () => { const response = await fastify.inject({ method: 'PATCH', - url: '/tasks/id/1', + url: '/v1/tasks/id/1', payload: {status: 'done'}, }); @@ -321,7 +321,7 @@ describe('test edit api', () => { const response = await fastify.inject({ method: 'PATCH', - url: '/tasks/id/1', + url: '/v1/tasks/id/1', payload: {status: 'unknown_status'}, }); @@ -338,7 +338,7 @@ describe('test edit api', () => { await fastify.inject({ method: 'PATCH', - url: '/tasks/status/pending?id_lt=10', // editing tasks with status pending AND id < 10 + url: '/v1/tasks/status/pending?id_lt=10', // editing tasks with status pending AND id < 10 payload: { title: 'Urgent Pending Task', }, @@ -357,7 +357,7 @@ describe('test edit api', () => { await fastify.inject({ method: 'PATCH', - url: '/tasks/status/pending?id_lte=5', + url: '/v1/tasks/status/pending?id_lte=5', payload: {title: 'Update'}, }); @@ -374,7 +374,7 @@ describe('test edit api', () => { await fastify.inject({ method: 'PATCH', - url: '/tasks/status/pending?id_gt=1', + url: '/v1/tasks/status/pending?id_gt=1', payload: {title: 'Update'}, }); @@ -391,7 +391,7 @@ describe('test edit api', () => { await fastify.inject({ method: 'PATCH', - url: '/tasks/status/pending?id_gte=2', + url: '/v1/tasks/status/pending?id_gte=2', payload: {title: 'Update'}, }); @@ -408,7 +408,7 @@ describe('test edit api', () => { await fastify.inject({ method: 'PATCH', - url: '/tasks/status/pending?page=1&limit=10&orderBy=id&orderDir=asc', + url: '/v1/tasks/status/pending?page=1&limit=10&orderBy=id&orderDir=asc', payload: { title: 'Ignored Params Task', }, @@ -428,7 +428,7 @@ describe('test edit api', () => { await fastify.inject({ method: 'PATCH', - url: '/tasks/status/pending?id_ne=10', + url: '/v1/tasks/status/pending?id_ne=10', payload: {title: 'Update'}, }); @@ -445,7 +445,7 @@ describe('test edit api', () => { await fastify.inject({ method: 'PATCH', - url: '/tasks/status/pending?id_not_in=1,2,3', + url: '/v1/tasks/status/pending?id_not_in=1,2,3', payload: {title: 'Update'}, }); @@ -462,7 +462,7 @@ describe('test edit api', () => { await fastify.inject({ method: 'PATCH', - url: '/tasks/status/pending?title_eq=foo&id_in=1,2,3', + url: '/v1/tasks/status/pending?title_eq=foo&id_in=1,2,3', payload: { title: 'Bulk updated foo', }, @@ -488,7 +488,7 @@ describe('test edit api', () => { const response = await fastify.inject({ method: 'PATCH', - url: '/users/id/999', + url: '/v1/users/id/999', payload: {name: 'Nobody'}, }); @@ -510,7 +510,7 @@ describe('test edit api', () => { const response = await fastify.inject({ method: 'PUT', - url: '/users/id/999', + url: '/v1/users/id/999', payload: {name: 'Nobody', email: 'nobody@example.com'}, }); @@ -532,7 +532,7 @@ describe('test edit api', () => { const response = await fastify.inject({ method: 'PATCH', - url: '/users/id/1', + url: '/v1/users/id/1', payload: {name: 'Found'}, }); @@ -543,12 +543,12 @@ describe('test edit api', () => { test('should return 404 when the edit API is disabled via config', async () => { const fastify = await createTestApp(pgConfig, defaultEditModel, { - 'model.users.id.edit': {enabled: false}, + 'model.v1.users.id.edit': {enabled: false}, }); const response = await fastify.inject({ method: 'PATCH', - url: '/users/id/1', + url: '/v1/users/id/1', payload: {name: 'Bob'}, }); @@ -562,7 +562,7 @@ describe('test edit api', () => { const response = await fastify.inject({ method: 'PATCH', - url: '/users/id/1', + url: '/v1/users/id/1', payload: { unknownField: 'value', }, @@ -581,7 +581,7 @@ describe('test edit api', () => { const response = await fastify.inject({ method: 'PATCH', - url: '/users/name/Alice', // name is not editable identifying field + url: '/v1/users/name/Alice', // name is not editable identifying field payload: { email: 'alice@example.com', }, @@ -604,7 +604,7 @@ describe('test edit api', () => { const response = await fastify.inject({ method: 'PATCH', - url: '/users/id/1', + url: '/v1/users/id/1', payload: {name: 'Bob'}, }); @@ -623,7 +623,7 @@ describe('test edit api', () => { const response = await fastify.inject({ method: 'PATCH', - url: '/users/id/1', + url: '/v1/users/id/1', payload: {name: 'Bob'}, }); @@ -642,7 +642,7 @@ describe('test edit api', () => { const response = await fastify.inject({ method: 'PUT', - url: '/users/id/1', + url: '/v1/users/id/1', payload: {name: 'Bob', email: 'bob@example.com'}, }); @@ -658,7 +658,7 @@ describe('test edit api', () => { const response = await fastify.inject({ method: 'PATCH', - url: '/users/id/1', + url: '/v1/users/id/1', payload: {name: 'Bob'}, }); @@ -670,7 +670,7 @@ describe('test edit api', () => { describe('authentication', () => { const apisConfig = { - 'model.users.id.edit': { + 'model.v1.users.id.edit': { enabled: true, authorization: true, }, @@ -687,7 +687,7 @@ describe('test edit api', () => { const response = await fastify.inject({ method: 'PATCH', - url: '/users/id/1', + url: '/v1/users/id/1', payload: {name: 'Bob'}, }); @@ -706,7 +706,7 @@ describe('test edit api', () => { const response = await fastify.inject({ method: 'PUT', - url: '/users/id/1', + url: '/v1/users/id/1', payload: {name: 'Bob', email: 'bob@example.com'}, }); @@ -732,7 +732,7 @@ describe('test edit api', () => { const response = await fastify.inject({ method: 'PATCH', - url: '/users/id/1', + url: '/v1/users/id/1', headers: { authorization: `Bearer ${token}`, }, @@ -743,4 +743,66 @@ describe('test edit api', () => { await fastify.close(); }); }); + + describe('API variants', () => { + test('should register additional variant endpoint when apiVariants is configured', async () => { + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({rows: [], rowCount: 1}) // UPDATE + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + + const fastify = await createTestApp( + pgConfig, + defaultEditModel, + undefined, + undefined, + undefined, + { + 'model.v1.users.id.edit': { + variants: ['admin'], + }, + }, + ); + + const response = await fastify.inject({ + method: 'PATCH', + url: '/admin/users/id/1', + payload: {name: 'Bob'}, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data).toEqual({name: 'Bob'}); + + await fastify.close(); + }); + + test('should not register variant endpoint when disabled in apis config', async () => { + const fastify = await createTestApp( + pgConfig, + defaultEditModel, + { + 'model.admin.users.id.edit': { + enabled: false, + }, + }, + undefined, + undefined, + { + 'model.v1.users.id.edit': { + variants: ['admin'], + }, + }, + ); + + const response = await fastify.inject({ + method: 'PATCH', + url: '/admin/users/id/1', + payload: {name: 'Bob'}, + }); + + expect(response.statusCode).toBe(404); + + await fastify.close(); + }); + }); }); diff --git a/tests/routes/forgot-password.test.ts b/tests/routes/forgot-password.test.ts index 187d188..11b11c7 100644 --- a/tests/routes/forgot-password.test.ts +++ b/tests/routes/forgot-password.test.ts @@ -64,6 +64,7 @@ async function createAuthApp( models: Record = authModels, dbConfig: DatabaseConfig = pgConfig, apis?: Record, + apiVariants?: Record, ): Promise { const app = Fastify(); const config: AppConfig = { @@ -79,6 +80,7 @@ async function createAuthApp( data: {models}, authentication, ...(apis ? {apis} : {}), + ...(apiVariants ? {apiVariants} : {}), }; app.appConfig = config; @@ -133,7 +135,7 @@ describe('POST /auth/forgot-password', () => { const response = await app.inject({ method: 'POST', - url: '/auth/forgot-password', + url: '/v1/auth/forgot-password', payload: {email: 'test@example.com'}, }); @@ -146,7 +148,7 @@ describe('POST /auth/forgot-password', () => { const response = await app.inject({ method: 'POST', - url: '/auth/forgot-password', + url: '/v1/auth/forgot-password', payload: {email: 'test@example.com'}, }); @@ -157,12 +159,12 @@ describe('POST /auth/forgot-password', () => { test('should NOT register the route when the API is disabled via apis config', async () => { const app = await createAuthApp(upAuthConfig, authModels, pgConfig, { - 'auth.users.all.forgotPassword': {enabled: false}, + 'auth.v1.users.unknown.forgotPassword': {enabled: false}, }); const response = await app.inject({ method: 'POST', - url: '/auth/forgot-password', + url: '/v1/auth/forgot-password', payload: {email: 'test@example.com'}, }); @@ -186,7 +188,7 @@ describe('POST /auth/forgot-password', () => { const response = await app.inject({ method: 'POST', - url: '/auth/forgot-password', + url: '/v1/auth/forgot-password', payload: {email: 'alice@example.com'}, }); @@ -210,7 +212,7 @@ describe('POST /auth/forgot-password', () => { const response = await app.inject({ method: 'POST', - url: '/auth/forgot-password', + url: '/v1/auth/forgot-password', payload: {email: 'nonexistent@example.com'}, }); @@ -226,7 +228,7 @@ describe('POST /auth/forgot-password', () => { const response = await app.inject({ method: 'POST', - url: '/auth/forgot-password', + url: '/v1/auth/forgot-password', payload: {}, }); @@ -234,4 +236,62 @@ describe('POST /auth/forgot-password', () => { await app.close(); }); }); + + describe('API variants', () => { + test('should register additional variant endpoint when apiVariants is configured', async () => { + pgQueryMock.mockResolvedValueOnce({ + rows: [{id: 1, email: 'alice@example.com', password: 'hashed'}], + rowCount: 1, + }); + + const app = await createAuthApp( + upAuthConfig, + authModels, + pgConfig, + undefined, + { + 'auth.v1.users.unknown.forgotPassword': { + variants: ['admin'], + }, + }, + ); + + const response = await app.inject({ + method: 'POST', + url: '/admin/auth/forgot-password', + payload: {email: 'alice@example.com'}, + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.requiresMfa).toBe(true); + await app.close(); + }); + + test('should not register variant endpoint when disabled in apis config', async () => { + const app = await createAuthApp( + upAuthConfig, + authModels, + pgConfig, + { + 'auth.admin.users.unknown.forgotPassword': { + enabled: false, + }, + }, + { + 'auth.v1.users.unknown.forgotPassword': { + variants: ['admin'], + }, + }, + ); + + const response = await app.inject({ + method: 'POST', + url: '/admin/auth/forgot-password', + payload: {email: 'alice@example.com'}, + }); + + expect(response.statusCode).toBe(404); + await app.close(); + }); + }); }); diff --git a/tests/routes/get-all.test.ts b/tests/routes/get-all.test.ts index 06ec87a..d64303d 100644 --- a/tests/routes/get-all.test.ts +++ b/tests/routes/get-all.test.ts @@ -65,7 +65,7 @@ describe('test get-all api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/', + url: '/v1/users/', }); expect(response.statusCode).toBe(200); @@ -85,7 +85,7 @@ describe('test get-all api', () => { test('should build the correct SELECT SQL with default pagination', async () => { const fastify = await createTestApp(pgConfig, getAllModel); - await fastify.inject({method: 'GET', url: '/users/'}); + await fastify.inject({method: 'GET', url: '/v1/users/'}); expect(pgClientQueryMock).toHaveBeenCalledWith( 'SELECT * FROM "users" LIMIT $1 OFFSET $2;', @@ -106,7 +106,7 @@ describe('test get-all api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/', + url: '/v1/users/', }); expect(response.json().data.data).toEqual([]); @@ -119,7 +119,7 @@ describe('test get-all api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/', + url: '/v1/users/', }); expect(response.json().message).toBe( @@ -136,7 +136,7 @@ describe('test get-all api', () => { await fastify.inject({ method: 'GET', - url: '/users/?page=2&limit=10', + url: '/v1/users/?page=2&limit=10', }); expect(pgClientQueryMock).toHaveBeenCalledWith( @@ -150,7 +150,7 @@ describe('test get-all api', () => { test('should default to page=1 when page param is 0 or missing', async () => { const fastify = await createTestApp(pgConfig, getAllModel); - await fastify.inject({method: 'GET', url: '/users/?page=0'}); + await fastify.inject({method: 'GET', url: '/v1/users/?page=0'}); expect(pgClientQueryMock).toHaveBeenCalledWith( 'SELECT * FROM "users" LIMIT $1 OFFSET $2;', @@ -165,7 +165,7 @@ describe('test get-all api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/?page=3&limit=15', + url: '/v1/users/?page=3&limit=15', }); expect(response.json().data.pagination).toEqual({ @@ -183,7 +183,7 @@ describe('test get-all api', () => { test('should apply _eq filter in WHERE clause', async () => { const fastify = await createTestApp(pgConfig, getAllModel); - await fastify.inject({method: 'GET', url: '/users/?name_eq=Alice'}); + await fastify.inject({method: 'GET', url: '/v1/users/?name_eq=Alice'}); expect(pgClientQueryMock).toHaveBeenCalledWith( 'SELECT * FROM "users" WHERE "name" = $1 LIMIT $2 OFFSET $3;', @@ -196,7 +196,7 @@ describe('test get-all api', () => { test('should apply _lt filter in WHERE clause', async () => { const fastify = await createTestApp(pgConfig, getAllModel); - await fastify.inject({method: 'GET', url: '/users/?id_lt=10'}); + await fastify.inject({method: 'GET', url: '/v1/users/?id_lt=10'}); expect(pgClientQueryMock).toHaveBeenCalledWith( 'SELECT * FROM "users" WHERE "id" < $1 LIMIT $2 OFFSET $3;', @@ -209,7 +209,7 @@ describe('test get-all api', () => { test('should apply _lte filter in WHERE clause', async () => { const fastify = await createTestApp(pgConfig, getAllModel); - await fastify.inject({method: 'GET', url: '/users/?id_lte=100'}); + await fastify.inject({method: 'GET', url: '/v1/users/?id_lte=100'}); expect(pgClientQueryMock).toHaveBeenCalledWith( 'SELECT * FROM "users" WHERE "id" <= $1 LIMIT $2 OFFSET $3;', @@ -222,7 +222,7 @@ describe('test get-all api', () => { test('should apply _gt filter in WHERE clause', async () => { const fastify = await createTestApp(pgConfig, getAllModel); - await fastify.inject({method: 'GET', url: '/users/?id_gt=5'}); + await fastify.inject({method: 'GET', url: '/v1/users/?id_gt=5'}); expect(pgClientQueryMock).toHaveBeenCalledWith( 'SELECT * FROM "users" WHERE "id" > $1 LIMIT $2 OFFSET $3;', @@ -235,7 +235,7 @@ describe('test get-all api', () => { test('should apply _gte filter in WHERE clause', async () => { const fastify = await createTestApp(pgConfig, getAllModel); - await fastify.inject({method: 'GET', url: '/users/?id_gte=1'}); + await fastify.inject({method: 'GET', url: '/v1/users/?id_gte=1'}); expect(pgClientQueryMock).toHaveBeenCalledWith( 'SELECT * FROM "users" WHERE "id" >= $1 LIMIT $2 OFFSET $3;', @@ -248,7 +248,7 @@ describe('test get-all api', () => { test('should apply _in filter in WHERE clause', async () => { const fastify = await createTestApp(pgConfig, getAllModel); - await fastify.inject({method: 'GET', url: '/users/?id_in=1,2,3'}); + await fastify.inject({method: 'GET', url: '/v1/users/?id_in=1,2,3'}); expect(pgClientQueryMock).toHaveBeenCalledWith( 'SELECT * FROM "users" WHERE "id" IN ($1, $2, $3) LIMIT $4 OFFSET $5;', @@ -261,7 +261,7 @@ describe('test get-all api', () => { test('should apply _ne filter in WHERE clause', async () => { const fastify = await createTestApp(pgConfig, getAllModel); - await fastify.inject({method: 'GET', url: '/users/?name_ne=Alice'}); + await fastify.inject({method: 'GET', url: '/v1/users/?name_ne=Alice'}); expect(pgClientQueryMock).toHaveBeenCalledWith( 'SELECT * FROM "users" WHERE "name" != $1 LIMIT $2 OFFSET $3;', @@ -276,7 +276,7 @@ describe('test get-all api', () => { await fastify.inject({ method: 'GET', - url: '/users/?id_not_in=1,2,3', + url: '/v1/users/?id_not_in=1,2,3', }); expect(pgClientQueryMock).toHaveBeenCalledWith( @@ -292,7 +292,7 @@ describe('test get-all api', () => { await fastify.inject({ method: 'GET', - url: '/users/?name_eq=Bob&id_gt=10', + url: '/v1/users/?name_eq=Bob&id_gt=10', }); const callArgs = pgClientQueryMock.mock.calls[2]; @@ -310,7 +310,7 @@ describe('test get-all api', () => { test('should apply ORDER BY ASC when orderBy is set', async () => { const fastify = await createTestApp(pgConfig, getAllModel); - await fastify.inject({method: 'GET', url: '/users/?orderBy=name'}); + await fastify.inject({method: 'GET', url: '/v1/users/?orderBy=name'}); expect(pgClientQueryMock).toHaveBeenCalledWith( 'SELECT * FROM "users" ORDER BY "name" ASC LIMIT $1 OFFSET $2;', @@ -325,7 +325,7 @@ describe('test get-all api', () => { await fastify.inject({ method: 'GET', - url: '/users/?orderBy=id&orderDir=desc', + url: '/v1/users/?orderBy=id&orderDir=desc', }); expect(pgClientQueryMock).toHaveBeenCalledWith( @@ -339,7 +339,7 @@ describe('test get-all api', () => { test('should not add ORDER BY clause when orderBy is absent', async () => { const fastify = await createTestApp(pgConfig, getAllModel); - await fastify.inject({method: 'GET', url: '/users/?orderDir=desc'}); + await fastify.inject({method: 'GET', url: '/v1/users/?orderDir=desc'}); expect(pgClientQueryMock).toHaveBeenCalledWith( 'SELECT * FROM "users" LIMIT $1 OFFSET $2;', @@ -353,12 +353,12 @@ describe('test get-all api', () => { describe('error handling', () => { test('should return 404 when the get-all API is disabled via config', async () => { const fastify = await createTestApp(pgConfig, getAllModel, { - 'model.users.all.getAll': {enabled: false}, + 'model.v1.users.unknown.getAll': {enabled: false}, }); const response = await fastify.inject({ method: 'GET', - url: '/users/', + url: '/v1/users/', }); expect(response.statusCode).toBe(404); @@ -372,7 +372,7 @@ describe('test get-all api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/', + url: '/v1/users/', }); expect(response.statusCode).toBe(500); @@ -390,7 +390,7 @@ describe('test get-all api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/', + url: '/v1/users/', }); expect(response.statusCode).toBe(500); @@ -408,7 +408,7 @@ describe('test get-all api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/', + url: '/v1/users/', }); expect(response.statusCode).toBe(500); @@ -423,7 +423,7 @@ describe('test get-all api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/?limit=0', + url: '/v1/users/?limit=0', }); expect(response.statusCode).toBe(400); @@ -438,7 +438,7 @@ describe('test get-all api', () => { const response = await fastify.inject({ method: 'GET', - url: '/unknown-table/', + url: '/v1/unknown-table/', }); expect(response.statusCode).toBe(404); @@ -458,7 +458,7 @@ describe('test get-all api', () => { const fastify = await createTestApp(pgConfig, emptyModel); - const response = await fastify.inject({method: 'GET', url: '/tags/'}); + const response = await fastify.inject({method: 'GET', url: '/v1/tags/'}); expect(response.statusCode).toBe(200); expect(response.json().data.data).toHaveLength(1); @@ -475,7 +475,7 @@ describe('test get-all api', () => { const fastify = await createTestApp(pgConfig, getAllModel); - const response = await fastify.inject({method: 'GET', url: '/users/'}); + const response = await fastify.inject({method: 'GET', url: '/v1/users/'}); expect(response.statusCode).toBe(200); expect(response.json().data.data).toEqual([]); @@ -486,7 +486,7 @@ describe('test get-all api', () => { test('should ignore unknown query params that do not match filter patterns', async () => { const fastify = await createTestApp(pgConfig, getAllModel); - await fastify.inject({method: 'GET', url: '/users/?foo=bar'}); + await fastify.inject({method: 'GET', url: '/v1/users/?foo=bar'}); // Should not have a WHERE clause for foo expect(pgClientQueryMock).toHaveBeenCalledWith( @@ -500,7 +500,7 @@ describe('test get-all api', () => { describe('authentication', () => { const apisConfig = { - 'model.users.all.getAll': { + 'model.v1.users.unknown.getAll': { enabled: true, authorization: true, }, @@ -517,7 +517,7 @@ describe('test get-all api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/', + url: '/v1/users/', }); expect(response.statusCode).toBe(401); @@ -546,7 +546,7 @@ describe('test get-all api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/', + url: '/v1/users/', headers: { authorization: `Bearer ${token}`, }, @@ -557,4 +557,72 @@ describe('test get-all api', () => { await fastify.close(); }); }); + + describe('API variants', () => { + test('should register additional variant endpoint when apiVariants is configured', async () => { + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({rows: [{total: 2}]}) // COUNT + .mockResolvedValueOnce({ + rows: [ + {id: 1, name: 'Alice', email: 'alice@example.com'}, + {id: 2, name: 'Bob', email: 'bob@example.com'}, + ], + rowCount: 2, + }) + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + + const fastify = await createTestApp( + pgConfig, + getAllModel, + undefined, + undefined, + undefined, + { + 'model.v1.users.unknown.getAll': { + variants: ['admin'], + }, + }, + ); + + const response = await fastify.inject({ + method: 'GET', + url: '/admin/users/', + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.data).toHaveLength(2); + expect(response.json().data.data[0].name).toBe('Alice'); + + await fastify.close(); + }); + + test('should not register variant endpoint when disabled in apis config', async () => { + const fastify = await createTestApp( + pgConfig, + getAllModel, + { + 'model.admin.users.unknown.getAll': { + enabled: false, + }, + }, + undefined, + undefined, + { + 'model.v1.users.unknown.getAll': { + variants: ['admin'], + }, + }, + ); + + const response = await fastify.inject({ + method: 'GET', + url: '/admin/users/', + }); + + expect(response.statusCode).toBe(404); + + await fastify.close(); + }); + }); }); diff --git a/tests/routes/index-route.test.ts b/tests/routes/index-route.test.ts index e3b7fc4..6e6a538 100644 --- a/tests/routes/index-route.test.ts +++ b/tests/routes/index-route.test.ts @@ -112,7 +112,7 @@ describe('test index-route api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/id/42', + url: '/v1/users/id/42', }); expect(response.statusCode).toBe(200); @@ -128,7 +128,7 @@ describe('test index-route api', () => { test('should build correct SQL with LIMIT 1 for primaryKey field', async () => { const fastify = await createTestApp(pgConfig, uniqueFieldModel); - await fastify.inject({method: 'GET', url: '/users/id/5'}); + await fastify.inject({method: 'GET', url: '/v1/users/id/5'}); expect(pgClientQueryMock).toHaveBeenNthCalledWith( 2, @@ -149,7 +149,7 @@ describe('test index-route api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/id/999', + url: '/v1/users/id/999', }); expect(response.statusCode).toBe(200); @@ -163,7 +163,7 @@ describe('test index-route api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/id/1', + url: '/v1/users/id/1', }); expect(response.json().data).not.toHaveProperty('pagination'); @@ -184,7 +184,7 @@ describe('test index-route api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/email/bob@example.com', + url: '/v1/users/email/bob@example.com', }); expect(response.statusCode).toBe(200); @@ -202,7 +202,7 @@ describe('test index-route api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/id/1', + url: '/v1/users/id/1', }); expect(response.json().message).toBe( @@ -231,7 +231,7 @@ describe('test index-route api', () => { const response = await fastify.inject({ method: 'GET', - url: '/posts/category/tech', + url: '/v1/posts/category/tech', }); expect(response.statusCode).toBe(200); @@ -248,7 +248,7 @@ describe('test index-route api', () => { await fastify.inject({ method: 'GET', - url: '/posts/category/tech', + url: '/v1/posts/category/tech', }); expect(pgClientQueryMock).toHaveBeenNthCalledWith( @@ -265,7 +265,7 @@ describe('test index-route api', () => { const response = await fastify.inject({ method: 'GET', - url: '/posts/category/tech?page=2&limit=15', + url: '/v1/posts/category/tech?page=2&limit=15', }); expect(response.json().data.pagination).toEqual({ @@ -283,7 +283,7 @@ describe('test index-route api', () => { await fastify.inject({ method: 'GET', - url: '/posts/category/tech?page=3&limit=10', + url: '/v1/posts/category/tech?page=3&limit=10', }); expect(pgClientQueryMock).toHaveBeenNthCalledWith( @@ -300,7 +300,7 @@ describe('test index-route api', () => { await fastify.inject({ method: 'GET', - url: '/posts/category/tech?title_eq=Post+A', + url: '/v1/posts/category/tech?title_eq=Post+A', }); const callArgs = pgClientQueryMock.mock.calls[2]; @@ -316,7 +316,7 @@ describe('test index-route api', () => { const response = await fastify.inject({ method: 'GET', - url: '/posts/category/tech?orderBy=title', + url: '/v1/posts/category/tech?orderBy=title', }); expect(response.statusCode).toBe(200); @@ -331,7 +331,7 @@ describe('test index-route api', () => { await fastify.inject({ method: 'GET', - url: '/posts/category/tech?orderBy=category&orderDir=desc', + url: '/v1/posts/category/tech?orderBy=category&orderDir=desc', }); const callArgs = pgClientQueryMock.mock.calls[2]; @@ -345,7 +345,7 @@ describe('test index-route api', () => { const response = await fastify.inject({ method: 'GET', - url: '/posts/category/nonexistent', + url: '/v1/posts/category/nonexistent', }); expect(response.statusCode).toBe(200); @@ -365,7 +365,7 @@ describe('test index-route api', () => { const response = await fastify.inject({ method: 'GET', - url: '/posts/category/tech', + url: '/v1/posts/category/tech', }); expect(response.statusCode).toBe(200); @@ -379,7 +379,7 @@ describe('test index-route api', () => { await fastify.inject({ method: 'GET', - url: '/posts/category/tech?id_lt=100', + url: '/v1/posts/category/tech?id_lt=100', }); expect(pgClientQueryMock).toHaveBeenNthCalledWith( @@ -396,7 +396,7 @@ describe('test index-route api', () => { await fastify.inject({ method: 'GET', - url: '/posts/category/tech?id_ne=99', + url: '/v1/posts/category/tech?id_ne=99', }); expect(pgClientQueryMock).toHaveBeenNthCalledWith( @@ -413,7 +413,7 @@ describe('test index-route api', () => { await fastify.inject({ method: 'GET', - url: '/posts/category/tech?id_lte=50', + url: '/v1/posts/category/tech?id_lte=50', }); expect(pgClientQueryMock).toHaveBeenNthCalledWith( @@ -430,7 +430,7 @@ describe('test index-route api', () => { await fastify.inject({ method: 'GET', - url: '/posts/category/tech?id_gt=10', + url: '/v1/posts/category/tech?id_gt=10', }); expect(pgClientQueryMock).toHaveBeenNthCalledWith( @@ -447,7 +447,7 @@ describe('test index-route api', () => { await fastify.inject({ method: 'GET', - url: '/posts/category/tech?id_gte=1', + url: '/v1/posts/category/tech?id_gte=1', }); expect(pgClientQueryMock).toHaveBeenNthCalledWith( @@ -464,7 +464,7 @@ describe('test index-route api', () => { await fastify.inject({ method: 'GET', - url: '/posts/category/tech?id_in=1,2,3', + url: '/v1/posts/category/tech?id_in=1,2,3', }); expect(pgClientQueryMock).toHaveBeenNthCalledWith( @@ -481,7 +481,7 @@ describe('test index-route api', () => { await fastify.inject({ method: 'GET', - url: '/posts/category/tech?id_gt=10&title_eq=Hello', + url: '/v1/posts/category/tech?id_gt=10&title_eq=Hello', }); const callArgs = pgClientQueryMock.mock.calls[2]; @@ -500,7 +500,10 @@ describe('test index-route api', () => { const fastify = await createTestApp(pgConfig, mixedFieldModel); // unique route: /articles/id/:id - const byId = await fastify.inject({method: 'GET', url: '/articles/id/1'}); + const byId = await fastify.inject({ + method: 'GET', + url: '/v1/articles/id/1', + }); expect(byId.statusCode).toBe(200); expect(pgClientQueryMock).toHaveBeenNthCalledWith( 2, @@ -513,7 +516,7 @@ describe('test index-route api', () => { // unique route: /articles/slug/:slug const bySlug = await fastify.inject({ method: 'GET', - url: '/articles/slug/my-article', + url: '/v1/articles/slug/my-article', }); expect(bySlug.statusCode).toBe(200); expect(pgClientQueryMock).toHaveBeenNthCalledWith( @@ -527,7 +530,7 @@ describe('test index-route api', () => { // indexable route: /articles/tag/:tag const byTag = await fastify.inject({ method: 'GET', - url: '/articles/tag/news', + url: '/v1/articles/tag/news', }); expect(byTag.statusCode).toBe(200); expect(pgClientQueryMock).toHaveBeenNthCalledWith( @@ -543,12 +546,12 @@ describe('test index-route api', () => { describe('error handling', () => { test('should return 404 when the index API is disabled via config', async () => { const fastify = await createTestApp(pgConfig, uniqueFieldModel, { - 'model.users.id.index': {enabled: false}, + 'model.v1.users.id.index': {enabled: false}, }); const response = await fastify.inject({ method: 'GET', - url: '/users/id/42', + url: '/v1/users/id/42', }); expect(response.statusCode).toBe(404); @@ -564,7 +567,7 @@ describe('test index-route api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/id/1', + url: '/v1/users/id/1', }); expect(response.statusCode).toBe(500); @@ -580,7 +583,7 @@ describe('test index-route api', () => { const response = await fastify.inject({ method: 'GET', - url: '/posts/category/tech', + url: '/v1/posts/category/tech', }); expect(response.statusCode).toBe(500); @@ -598,7 +601,7 @@ describe('test index-route api', () => { const response = await fastify.inject({ method: 'GET', - url: '/posts/category/tech', + url: '/v1/posts/category/tech', }); expect(response.statusCode).toBe(500); @@ -613,7 +616,7 @@ describe('test index-route api', () => { const response = await fastify.inject({ method: 'GET', - url: '/logs/message/hello', + url: '/v1/logs/message/hello', }); expect(response.statusCode).toBe(404); @@ -627,7 +630,7 @@ describe('test index-route api', () => { const response = await fastify.inject({ method: 'GET', - url: '/nonexistent/id/1', + url: '/v1/nonexistent/id/1', }); expect(response.statusCode).toBe(404); @@ -639,7 +642,7 @@ describe('test index-route api', () => { describe('authentication', () => { const apisConfig = { - 'model.users.id.index': { + 'model.v1.users.id.index': { enabled: true, authorization: true, }, @@ -656,7 +659,7 @@ describe('test index-route api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/id/42', + url: '/v1/users/id/42', }); expect(response.statusCode).toBe(401); @@ -684,7 +687,7 @@ describe('test index-route api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/id/42', + url: '/v1/users/id/42', headers: { authorization: `Bearer ${token}`, }, @@ -695,4 +698,71 @@ describe('test index-route api', () => { await fastify.close(); }); }); + + describe('API variants', () => { + test('should register additional variant endpoint when apiVariants is configured', async () => { + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({ + rows: [{id: 42, name: 'Alice', email: 'alice@example.com'}], + rowCount: 1, + }) // SELECT + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + + const fastify = await createTestApp( + pgConfig, + uniqueFieldModel, + undefined, + undefined, + undefined, + { + 'model.v1.users.id.index': { + variants: ['admin'], + }, + }, + ); + + const response = await fastify.inject({ + method: 'GET', + url: '/admin/users/id/42', + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.data).toEqual({ + id: 42, + name: 'Alice', + email: 'alice@example.com', + }); + + await fastify.close(); + }); + + test('should not register variant endpoint when disabled in apis config', async () => { + const fastify = await createTestApp( + pgConfig, + uniqueFieldModel, + { + 'model.admin.users.id.index': { + enabled: false, + }, + }, + undefined, + undefined, + { + 'model.v1.users.id.index': { + variants: ['admin'], + }, + }, + ); + + const response = await fastify.inject({ + method: 'GET', + url: '/admin/users/id/42', + }); + + expect(response.statusCode).toBe(404); + + await fastify.close(); + }); + }); }); diff --git a/tests/routes/login.test.ts b/tests/routes/login.test.ts index 458691c..5d4cc44 100644 --- a/tests/routes/login.test.ts +++ b/tests/routes/login.test.ts @@ -66,6 +66,7 @@ async function createAuthApp( models: Record = authModels, dbConfig: DatabaseConfig = pgConfig, apis?: Record, + apiVariants?: Record, ): Promise { const app = Fastify(); const config: AppConfig = { @@ -81,6 +82,7 @@ async function createAuthApp( data: {models}, authentication, ...(apis ? {apis} : {}), + ...(apiVariants ? {apiVariants} : {}), }; app.appConfig = config; await app.register(databasePlugin); @@ -166,7 +168,7 @@ describe('POST /auth/login', () => { const response = await app.inject({ method: 'POST', - url: '/auth/login', + url: '/v1/auth/login', payload: {email: 'test@example.com', password: 'password'}, }); @@ -179,7 +181,7 @@ describe('POST /auth/login', () => { const response = await app.inject({ method: 'POST', - url: '/auth/login', + url: '/v1/auth/login', payload: {email: 'test@example.com', password: 'password'}, }); @@ -190,12 +192,12 @@ describe('POST /auth/login', () => { test('should NOT register the route when the API is disabled via apis config', async () => { const app = await createAuthApp(upAuthConfig, authModels, pgConfig, { - 'auth.users.all.login': {enabled: false}, + 'auth.v1.users.unknown.login': {enabled: false}, }); const response = await app.inject({ method: 'POST', - url: '/auth/login', + url: '/v1/auth/login', payload: {email: 'test@example.com', password: 'password'}, }); @@ -224,7 +226,7 @@ describe('POST /auth/login', () => { const response = await app.inject({ method: 'POST', - url: '/auth/login', + url: '/v1/auth/login', payload: {email: 'alice@example.com', password: 'p@ssw0rd'}, }); @@ -274,7 +276,7 @@ describe('POST /auth/login', () => { const response = await app.inject({ method: 'POST', - url: '/auth/login', + url: '/v1/auth/login', payload: {email: 'alice@example.com', password: 'p@ssw0rd'}, }); @@ -313,7 +315,7 @@ describe('POST /auth/login', () => { const response = await app.inject({ method: 'POST', - url: '/auth/login', + url: '/v1/auth/login', payload: {email: 'alice@example.com', password: 'p@ssw0rd'}, }); @@ -365,7 +367,7 @@ describe('POST /auth/login', () => { const response = await app.inject({ method: 'POST', - url: '/auth/login', + url: '/v1/auth/login', payload: {email: 'alice@example.com', password: 'p@ssw0rd'}, }); @@ -409,7 +411,7 @@ describe('POST /auth/login', () => { const response = await app.inject({ method: 'POST', - url: '/auth/login', + url: '/v1/auth/login', payload: {email: 'alice@example.com', password: 'wrong'}, }); @@ -429,7 +431,7 @@ describe('POST /auth/login', () => { const response = await app.inject({ method: 'POST', - url: '/auth/login', + url: '/v1/auth/login', payload: {email: 'nonexistent@example.com', password: 'any'}, }); @@ -454,7 +456,7 @@ describe('POST /auth/login', () => { const response = await app.inject({ method: 'POST', - url: '/auth/login', + url: '/v1/auth/login', payload: {email: 'alice@example.com', password: 'wrong_password'}, }); @@ -470,7 +472,7 @@ describe('POST /auth/login', () => { const response = await app.inject({ method: 'POST', - url: '/auth/login', + url: '/v1/auth/login', payload: {email: 'alice@example.com'}, // missing password }); @@ -478,4 +480,62 @@ describe('POST /auth/login', () => { await app.close(); }); }); + + describe('API variants', () => { + test('should register admin variant endpoint when apiVariants is configured', async () => { + pgQueryMock.mockResolvedValueOnce({ + rows: [ + {id: 1, email: 'alice@example.com', password: 'hashed_password'}, + ], + rowCount: 1, + }); + vi.spyOn(bcrypt, 'compare').mockResolvedValue(true as never); + + const app = await createAuthApp( + upAuthConfig, + authModels, + pgConfig, + undefined, + { + 'auth.v1.users.unknown.login': { + variants: ['admin'], + }, + }, + ); + + const response = await app.inject({ + method: 'POST', + url: '/admin/auth/login', + payload: {email: 'alice@example.com', password: 'p@ssw0rd'}, + }); + + expect(response.statusCode).toBe(200); + await app.close(); + }); + + test('should not register admin variant when disabled in apis config', async () => { + const app = await createAuthApp( + upAuthConfig, + authModels, + pgConfig, + { + 'auth.admin.users.unknown.login': {enabled: false}, + }, + { + 'auth.v1.users.unknown.login': { + variants: ['admin'], + }, + }, + ); + + const response = await app.inject({ + method: 'POST', + url: '/admin/auth/login', + payload: {email: 'alice@example.com', password: 'p@ssw0rd'}, + }); + + expect(response.statusCode).toBe(404); + await app.close(); + }); + }); }); diff --git a/tests/routes/me.test.ts b/tests/routes/me.test.ts index afa152a..32bf5a3 100644 --- a/tests/routes/me.test.ts +++ b/tests/routes/me.test.ts @@ -61,6 +61,7 @@ async function createMeApp( models: Record = authModels, dbConfig: DatabaseConfig = pgConfig, apis?: Record, + apiVariants?: Record, ): Promise { const app = Fastify(); const config: AppConfig = { @@ -76,6 +77,7 @@ async function createMeApp( data: {models}, authentication, ...(apis ? {apis} : {}), + ...(apiVariants ? {apiVariants} : {}), }; app.appConfig = config; await app.register(databasePlugin); @@ -109,7 +111,7 @@ describe('GET /auth/user/me', () => { const response = await app.inject({ method: 'GET', - url: '/auth/user/me', + url: '/v1/auth/user/me', }); expect(response.statusCode).toBe(404); @@ -121,7 +123,7 @@ describe('GET /auth/user/me', () => { const response = await app.inject({ method: 'GET', - url: '/auth/user/me', + url: '/v1/auth/user/me', }); expect(response.statusCode).toBe(404); @@ -131,12 +133,12 @@ describe('GET /auth/user/me', () => { test('should NOT register the route when the API is disabled via apis config', async () => { const app = await createMeApp(upAuthConfig, authModels, pgConfig, { - 'auth.users.all.me': {enabled: false}, + 'auth.v1.users.unknown.me': {enabled: false}, }); const response = await app.inject({ method: 'GET', - url: '/auth/user/me', + url: '/v1/auth/user/me', }); expect(response.statusCode).toBe(404); @@ -151,7 +153,7 @@ describe('GET /auth/user/me', () => { const response = await app.inject({ method: 'GET', - url: '/auth/user/me', + url: '/v1/auth/user/me', }); expect(response.statusCode).toBe(401); @@ -166,7 +168,7 @@ describe('GET /auth/user/me', () => { const response = await app.inject({ method: 'GET', - url: '/auth/user/me', + url: '/v1/auth/user/me', headers: { authorization: 'Bearer invalidtoken', }, @@ -186,7 +188,7 @@ describe('GET /auth/user/me', () => { const response = await app.inject({ method: 'GET', - url: '/auth/user/me', + url: '/v1/auth/user/me', headers: { authorization: `Bearer ${token}`, }, @@ -216,7 +218,7 @@ describe('GET /auth/user/me', () => { const response = await app.inject({ method: 'GET', - url: '/auth/user/me', + url: '/v1/auth/user/me', headers: { authorization: `Bearer ${token}`, }, @@ -244,7 +246,7 @@ describe('GET /auth/user/me', () => { const response = await app.inject({ method: 'GET', - url: '/auth/user/me', + url: '/v1/auth/user/me', headers: { authorization: `Bearer ${token}`, }, @@ -255,4 +257,63 @@ describe('GET /auth/user/me', () => { await app.close(); }); }); + + describe('API variants', () => { + test('should register additional variant endpoint when apiVariants is configured', async () => { + const app = await createMeApp( + upAuthConfig, + authModels, + pgConfig, + undefined, + { + 'auth.v1.users.unknown.me': { + variants: ['admin'], + }, + }, + ); + + const token = app.jwt.sign({id: 1, email: 'admin@example.com'}); + pgQueryMock.mockResolvedValueOnce({ + rows: [{id: 1, email: 'admin@example.com', password: 'hash'}], + rowCount: 1, + }); + + const response = await app.inject({ + method: 'GET', + url: '/admin/auth/user/me', + headers: { + authorization: `Bearer ${token}`, + }, + }); + + expect(response.statusCode).toBe(200); + await app.close(); + }); + + test('should not register variant endpoint when disabled in apis config', async () => { + const app = await createMeApp( + upAuthConfig, + authModels, + pgConfig, + { + 'auth.admin.users.unknown.me': { + enabled: false, + }, + }, + { + 'auth.v1.users.unknown.me': { + variants: ['admin'], + }, + }, + ); + + const response = await app.inject({ + method: 'GET', + url: '/admin/auth/user/me', + }); + + expect(response.statusCode).toBe(404); + await app.close(); + }); + }); }); diff --git a/tests/routes/otp-verify.test.ts b/tests/routes/otp-verify.test.ts index eaf2310..2e7e90a 100644 --- a/tests/routes/otp-verify.test.ts +++ b/tests/routes/otp-verify.test.ts @@ -72,6 +72,7 @@ async function createOtpApp( models: Record = authModels, dbConfig: DatabaseConfig = pgConfig, apis?: Record, + apiVariants?: Record, ): Promise { const app = Fastify(); const config: AppConfig = { @@ -87,6 +88,7 @@ async function createOtpApp( data: {models}, authentication, ...(apis ? {apis} : {}), + ...(apiVariants ? {apiVariants} : {}), }; app.appConfig = config; @@ -144,7 +146,7 @@ describe('POST /auth/login/verify/otp', () => { const response = await app.inject({ method: 'POST', - url: '/auth/login/verify/otp', + url: '/v1/auth/login/verify/otp', payload: {ulid: 'test-ulid', otp: '123456', email: 'test@example.com'}, }); @@ -157,7 +159,7 @@ describe('POST /auth/login/verify/otp', () => { const response = await app.inject({ method: 'POST', - url: '/auth/login/verify/otp', + url: '/v1/auth/login/verify/otp', payload: {ulid: 'test-ulid', otp: '123456', email: 'test@example.com'}, }); @@ -168,12 +170,12 @@ describe('POST /auth/login/verify/otp', () => { test('should NOT register the route when the API is disabled via apis config', async () => { const app = await createOtpApp(upAuthConfig, authModels, pgConfig, { - 'auth.users.all.otp-verify-login': {enabled: false}, + 'auth.v1.users.unknown.otpVerifyLogin': {enabled: false}, }); const response = await app.inject({ method: 'POST', - url: '/auth/login/verify/otp', + url: '/v1/auth/login/verify/otp', payload: {ulid: 'test-ulid', otp: '123456', email: 'test@example.com'}, }); @@ -206,7 +208,7 @@ describe('POST /auth/login/verify/otp', () => { const response = await app.inject({ method: 'POST', - url: '/auth/login/verify/otp', + url: '/v1/auth/login/verify/otp', payload: {ulid, otp: '000000', email: 'alice@example.com'}, }); @@ -236,7 +238,7 @@ describe('POST /auth/login/verify/otp', () => { const response = await app.inject({ method: 'POST', - url: '/auth/login/verify/otp', + url: '/v1/auth/login/verify/otp', payload: { ulid: 'wrong-ulid', otp: '000000', @@ -254,7 +256,7 @@ describe('POST /auth/login/verify/otp', () => { const response = await app.inject({ method: 'POST', - url: '/auth/login/verify/otp', + url: '/v1/auth/login/verify/otp', payload: { ulid: 'nonexistent-ulid', otp: '000000', @@ -272,7 +274,7 @@ describe('POST /auth/login/verify/otp', () => { const response = await app.inject({ method: 'POST', - url: '/auth/login/verify/otp', + url: '/v1/auth/login/verify/otp', payload: {ulid: 'test-ulid'}, }); @@ -296,7 +298,7 @@ describe('POST /auth/login/verify/otp', () => { const response = await app.inject({ method: 'POST', - url: '/auth/login/verify/otp', + url: '/v1/auth/login/verify/otp', payload: {ulid, otp: '000000', email: 'alice@example.com'}, }); @@ -323,7 +325,7 @@ describe('POST /auth/login/verify/otp', () => { const response = await app.inject({ method: 'POST', - url: '/auth/login/verify/otp', + url: '/v1/auth/login/verify/otp', payload: {ulid, otp: '000000', email: 'unknown@example.com'}, }); @@ -332,6 +334,56 @@ describe('POST /auth/login/verify/otp', () => { await app.close(); }); }); + + describe('API variants', () => { + test('should register additional variant endpoint when apiVariants is configured', async () => { + const app = await createOtpApp( + upAuthConfig, + undefined, + undefined, + undefined, + { + 'auth.v1.users.unknown.otpVerifyLogin': { + variants: ['admin'], + }, + }, + ); + const sendResponse = + await app.otp.sendOTPForVerification('admin@example.com'); + const ulid = typeof sendResponse === 'string' ? sendResponse : ''; + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({ + rows: [{id: 1, email: 'admin@example.com', password: 'hashed'}], + rowCount: 1, + }) // SELECT + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + vi.spyOn(bcrypt, 'compare').mockResolvedValue(true as never); + const response = await app.inject({ + method: 'POST', + url: '/admin/auth/login/verify/otp', + payload: {ulid, otp: '000000', email: 'admin@example.com'}, + }); + expect(response.statusCode).toBe(200); + await app.close(); + }); + + test('should not register variant endpoint when disabled in apis config', async () => { + const app = await createOtpApp( + upAuthConfig, + undefined, + undefined, + {'auth.admin.users.unknown.otpVerifyLogin': {enabled: false}}, + {'auth.v1.users.unknown.otpVerifyLogin': {variants: ['admin']}}, + ); + const response = await app.inject({ + method: 'POST', + url: '/admin/auth/login/verify/otp', + }); + expect(response.statusCode).toBe(404); + await app.close(); + }); + }); }); describe('POST /auth/register/verify/otp', () => { @@ -351,7 +403,7 @@ describe('POST /auth/register/verify/otp', () => { const response = await app.inject({ method: 'POST', - url: '/auth/register/verify/otp', + url: '/v1/auth/register/verify/otp', payload: {ulid: 'test-ulid', otp: '123456', email: 'test@example.com'}, }); @@ -383,7 +435,7 @@ describe('POST /auth/register/verify/otp', () => { const response = await app.inject({ method: 'POST', - url: '/auth/register/verify/otp', + url: '/v1/auth/register/verify/otp', payload: {ulid, otp: '000000', email: 'alice@example.com'}, }); @@ -444,7 +496,7 @@ describe('POST /auth/register/verify/otp', () => { const response = await app.inject({ method: 'POST', - url: '/auth/register/verify/otp', + url: '/v1/auth/register/verify/otp', payload: {ulid, otp: '000000', email: 'alice@example.com'}, }); @@ -475,7 +527,7 @@ describe('POST /auth/register/verify/otp', () => { const response = await app.inject({ method: 'POST', - url: '/auth/register/verify/otp', + url: '/v1/auth/register/verify/otp', payload: { ulid: 'wrong-ulid', otp: '000000', @@ -493,7 +545,7 @@ describe('POST /auth/register/verify/otp', () => { const response = await app.inject({ method: 'POST', - url: '/auth/register/verify/otp', + url: '/v1/auth/register/verify/otp', payload: {ulid: 'test-ulid'}, }); @@ -501,6 +553,57 @@ describe('POST /auth/register/verify/otp', () => { await app.close(); }); }); + + describe('API variants', () => { + test('should register additional variant endpoint when apiVariants is configured', async () => { + const app = await createOtpApp( + upAuthConfig, + undefined, + undefined, + undefined, + { + 'auth.v1.users.unknown.otpVerifyRegister': { + variants: ['admin'], + }, + }, + ); + const sendResponse = + await app.otp.sendOTPForVerification('admin@example.com'); + const ulid = typeof sendResponse === 'string' ? sendResponse : ''; + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({ + rows: [{id: 1, email: 'admin@example.com', is_active: false}], + rowCount: 1, + }) // SELECT + .mockResolvedValueOnce({rows: [], rowCount: 1}) // UPDATE + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + vi.spyOn(bcrypt, 'compare').mockResolvedValue(true as never); + const response = await app.inject({ + method: 'POST', + url: '/admin/auth/register/verify/otp', + payload: {ulid, otp: '000000', email: 'admin@example.com'}, + }); + expect(response.statusCode).toBe(200); + await app.close(); + }); + + test('should not register variant endpoint when disabled in apis config', async () => { + const app = await createOtpApp( + upAuthConfig, + undefined, + undefined, + {'auth.admin.users.unknown.otpVerifyRegister': {enabled: false}}, + {'auth.v1.users.unknown.otpVerifyRegister': {variants: ['admin']}}, + ); + const response = await app.inject({ + method: 'POST', + url: '/admin/auth/register/verify/otp', + }); + expect(response.statusCode).toBe(404); + await app.close(); + }); + }); }); describe('POST /auth/forgot-password/verify/otp', () => { @@ -520,7 +623,7 @@ describe('POST /auth/forgot-password/verify/otp', () => { const response = await app.inject({ method: 'POST', - url: '/auth/forgot-password/verify/otp', + url: '/v1/auth/forgot-password/verify/otp', payload: { ulid: 'test-ulid', otp: '123456', @@ -560,7 +663,7 @@ describe('POST /auth/forgot-password/verify/otp', () => { const response = await app.inject({ method: 'POST', - url: '/auth/forgot-password/verify/otp', + url: '/v1/auth/forgot-password/verify/otp', payload: { ulid, otp: '000000', @@ -599,7 +702,7 @@ describe('POST /auth/forgot-password/verify/otp', () => { const response = await app.inject({ method: 'POST', - url: '/auth/forgot-password/verify/otp', + url: '/v1/auth/forgot-password/verify/otp', payload: { ulid: 'wrong-ulid', otp: '000000', @@ -618,7 +721,7 @@ describe('POST /auth/forgot-password/verify/otp', () => { const response = await app.inject({ method: 'POST', - url: '/auth/forgot-password/verify/otp', + url: '/v1/auth/forgot-password/verify/otp', payload: { ulid: 'test-ulid', otp: '000000', @@ -630,4 +733,67 @@ describe('POST /auth/forgot-password/verify/otp', () => { await app.close(); }); }); + + describe('API variants', () => { + test('should register additional variant endpoint when apiVariants is configured', async () => { + const app = await createOtpApp( + upAuthConfig, + undefined, + undefined, + undefined, + { + 'auth.v1.users.unknown.otpVerifyForgotPassword': { + variants: ['admin'], + }, + }, + ); + const sendResponse = + await app.otp.sendOTPForVerification('admin@example.com'); + const ulid = typeof sendResponse === 'string' ? sendResponse : ''; + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({ + rows: [{id: 1, email: 'admin@example.com', password: 'old_hashed'}], + rowCount: 1, + }) // SELECT + .mockResolvedValueOnce({rows: [], rowCount: 1}) // UPDATE + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + vi.spyOn(bcrypt, 'compare').mockResolvedValue(true as never); + vi.spyOn(bcrypt, 'hash').mockResolvedValue( + 'new_hashed_password' as never, + ); + const response = await app.inject({ + method: 'POST', + url: '/admin/auth/forgot-password/verify/otp', + payload: { + ulid, + otp: '000000', + email: 'admin@example.com', + newPassword: 'newPass123', + }, + }); + expect(response.statusCode).toBe(200); + await app.close(); + }); + + test('should not register variant endpoint when disabled in apis config', async () => { + const app = await createOtpApp( + upAuthConfig, + undefined, + undefined, + {'auth.admin.users.unknown.otpVerifyForgotPassword': {enabled: false}}, + { + 'auth.v1.users.unknown.otpVerifyForgotPassword': { + variants: ['admin'], + }, + }, + ); + const response = await app.inject({ + method: 'POST', + url: '/admin/auth/forgot-password/verify/otp', + }); + expect(response.statusCode).toBe(404); + await app.close(); + }); + }); }); diff --git a/tests/routes/post.test.ts b/tests/routes/post.test.ts index acfb413..426badf 100644 --- a/tests/routes/post.test.ts +++ b/tests/routes/post.test.ts @@ -33,7 +33,7 @@ describe('test post api', () => { const response = await fastify.inject({ method: 'POST', - url: '/users/', + url: '/v1/users/', payload: { name: 'Test User', email: 'test@example.com', @@ -62,7 +62,7 @@ describe('test post api', () => { await fastify.inject({ method: 'POST', - url: '/users/', + url: '/v1/users/', payload: {name: 'Alice', email: 'alice@example.com'}, }); @@ -79,7 +79,7 @@ describe('test post api', () => { await fastify.inject({ method: 'POST', - url: '/users/', + url: '/v1/users/', payload: { name: 'Bob', email: 'bob@example.com', @@ -123,7 +123,7 @@ describe('test post api', () => { const response = await fastify.inject({ method: 'POST', - url: '/products/', + url: '/v1/products/', payload: {id: 1}, // missing 'title' }); @@ -151,7 +151,7 @@ describe('test post api', () => { const response = await fastify.inject({ method: 'POST', - url: '/items/', + url: '/v1/items/', payload: {count: 'not-a-number'}, // should be integer }); @@ -175,7 +175,7 @@ describe('test post api', () => { const response = await fastify.inject({ method: 'POST', - url: '/orders/', + url: '/v1/orders/', payload: {id: 1, status: 'shipped'}, }); @@ -199,7 +199,7 @@ describe('test post api', () => { const response = await fastify.inject({ method: 'POST', - url: '/orders/', + url: '/v1/orders/', payload: {id: 1, status: 'cancelled'}, // not in the enum values }); @@ -213,12 +213,12 @@ describe('test post api', () => { describe('error handling', () => { test('should return 404 when the post API is disabled via config', async () => { const fastify = await createTestApp(pgConfig, mockModels, { - 'model.users.all.insert': {enabled: false}, + 'model.v1.users.unknown.insert': {enabled: false}, }); const response = await fastify.inject({ method: 'POST', - url: '/users/', + url: '/v1/users/', payload: {name: 'Test', email: 'test@example.com'}, }); @@ -236,7 +236,7 @@ describe('test post api', () => { const response = await fastify.inject({ method: 'POST', - url: '/users/', + url: '/v1/users/', payload: {name: 'Test', email: 'test@example.com'}, }); @@ -254,7 +254,7 @@ describe('test post api', () => { const response = await fastify.inject({ method: 'POST', - url: '/users/', + url: '/v1/users/', payload: {name: 'Test', email: 'test@example.com'}, }); @@ -270,7 +270,7 @@ describe('test post api', () => { const response = await fastify.inject({ method: 'POST', - url: '/nonexistent/', + url: '/v1/nonexistent/', payload: {}, }); @@ -283,7 +283,7 @@ describe('test post api', () => { describe('authentication', () => { const apisConfig = { - 'model.users.all.insert': { + 'model.v1.users.unknown.insert': { enabled: true, authorization: true, }, @@ -300,7 +300,7 @@ describe('test post api', () => { const response = await fastify.inject({ method: 'POST', - url: '/users/', + url: '/v1/users/', payload: {name: 'Test', email: 'test@example.com'}, }); @@ -321,7 +321,7 @@ describe('test post api', () => { const response = await fastify.inject({ method: 'POST', - url: '/users/', + url: '/v1/users/', headers: { authorization: `Bearer ${token}`, }, @@ -332,4 +332,69 @@ describe('test post api', () => { await fastify.close(); }); }); + + describe('API variants', () => { + test('should register additional variant endpoint when apiVariants is configured', async () => { + pgClientQueryMock + .mockResolvedValueOnce({rows: [], rowCount: 0}) // BEGIN + .mockResolvedValueOnce({rows: [], changes: 0}) // INSERT + .mockResolvedValueOnce({rows: [], rowCount: 0}); // COMMIT + + const fastify = await createTestApp( + pgConfig, + mockModels, + undefined, + undefined, + undefined, + { + 'model.v1.users.unknown.insert': { + variants: ['admin'], + }, + }, + ); + + const response = await fastify.inject({ + method: 'POST', + url: '/admin/users/', + payload: {name: 'Test User', email: 'test@example.com'}, + }); + + expect(response.statusCode).toBe(201); + expect(response.json().data).toEqual({ + name: 'Test User', + email: 'test@example.com', + }); + + await fastify.close(); + }); + + test('should not register variant endpoint when disabled in apis config', async () => { + const fastify = await createTestApp( + pgConfig, + mockModels, + { + 'model.admin.users.unknown.insert': { + enabled: false, + }, + }, + undefined, + undefined, + { + 'model.v1.users.unknown.insert': { + variants: ['admin'], + }, + }, + ); + + const response = await fastify.inject({ + method: 'POST', + url: '/admin/users/', + payload: {name: 'Test', email: 'test@example.com'}, + }); + + expect(response.statusCode).toBe(404); + + await fastify.close(); + }); + }); }); diff --git a/tests/routes/registration.test.ts b/tests/routes/registration.test.ts index 31b739b..491e1e8 100644 --- a/tests/routes/registration.test.ts +++ b/tests/routes/registration.test.ts @@ -67,6 +67,7 @@ async function createAuthApp( models: Record = authModels, dbConfig: DatabaseConfig = pgConfig, apis?: Record, + apiVariants?: Record, ): Promise { const app = Fastify(); const config: AppConfig = { @@ -82,6 +83,7 @@ async function createAuthApp( data: {models}, authentication, ...(apis ? {apis} : {}), + ...(apiVariants ? {apiVariants} : {}), }; app.appConfig = config; await app.register(databasePlugin); @@ -120,7 +122,7 @@ describe('POST /auth/register', () => { const response = await app.inject({ method: 'POST', - url: '/auth/register', + url: '/v1/auth/register', payload: {email: 'a@b.com', password: 'secret'}, }); @@ -139,7 +141,7 @@ describe('POST /auth/register', () => { const response = await app.inject({ method: 'POST', - url: '/auth/register', + url: '/v1/auth/register', payload: {email: 'a@b.com', password: 'secret'}, }); @@ -154,7 +156,7 @@ describe('POST /auth/register', () => { const response = await app.inject({ method: 'POST', - url: '/auth/register', + url: '/v1/auth/register', payload: {email: 'a@b.com', password: 'secret'}, }); @@ -165,12 +167,12 @@ describe('POST /auth/register', () => { test('should NOT register the route when the API is disabled via apis config', async () => { const app = await createAuthApp(upAuthConfig, authModels, pgConfig, { - 'auth.users.all.registration': {enabled: false}, + 'auth.v1.users.unknown.registration': {enabled: false}, }); const response = await app.inject({ method: 'POST', - url: '/auth/register', + url: '/v1/auth/register', payload: {email: 'a@b.com', password: 'secret'}, }); @@ -190,7 +192,7 @@ describe('POST /auth/register', () => { const response = await app.inject({ method: 'POST', - url: '/auth/register', + url: '/v1/auth/register', payload: { email: 'alice@example.com', password: 'p@ssw0rd', @@ -220,7 +222,7 @@ describe('POST /auth/register', () => { await app.inject({ method: 'POST', - url: '/auth/register', + url: '/v1/auth/register', payload: {email: 'bob@example.com', password: 'mySecret'}, }); @@ -237,7 +239,7 @@ describe('POST /auth/register', () => { await app.inject({ method: 'POST', - url: '/auth/register', + url: '/v1/auth/register', payload: {email: 'carol@example.com', password: 'plaintext'}, }); @@ -259,7 +261,7 @@ describe('POST /auth/register', () => { await app.inject({ method: 'POST', - url: '/auth/register', + url: '/v1/auth/register', payload: {email: 'dave@example.com', password: 'secret'}, }); @@ -282,7 +284,7 @@ describe('POST /auth/register', () => { await app.inject({ method: 'POST', - url: '/auth/register', + url: '/v1/auth/register', payload: { email: 'eve@example.com', password: 'secret', @@ -312,7 +314,7 @@ describe('POST /auth/register', () => { await app.inject({ method: 'POST', - url: '/auth/register', + url: '/v1/auth/register', payload: { email: 'frank@example.com', password: 'secret', @@ -337,7 +339,7 @@ describe('POST /auth/register', () => { await app.inject({ method: 'POST', - url: '/auth/register', + url: '/v1/auth/register', payload: { id: 999, email: 'grace@example.com', @@ -366,7 +368,7 @@ describe('POST /auth/register', () => { const response = await app.inject({ method: 'POST', - url: '/auth/register', + url: '/v1/auth/register', payload: {password: 'secret'}, // email is required }); @@ -380,7 +382,7 @@ describe('POST /auth/register', () => { const response = await app.inject({ method: 'POST', - url: '/auth/register', + url: '/v1/auth/register', payload: {email: 'henry@example.com'}, // password is required }); @@ -394,7 +396,7 @@ describe('POST /auth/register', () => { const response = await app.inject({ method: 'POST', - url: '/auth/register', + url: '/v1/auth/register', payload: {}, }); @@ -410,7 +412,7 @@ describe('POST /auth/register', () => { const response = await app.inject({ method: 'POST', - url: '/auth/register', + url: '/v1/auth/register', payload: {email: 12345, password: 'secret'}, }); @@ -434,7 +436,7 @@ describe('POST /auth/register', () => { const response = await app.inject({ method: 'POST', - url: '/auth/register', + url: '/v1/auth/register', payload: {email: 'ivan@example.com', password: 'secret'}, }); @@ -451,7 +453,7 @@ describe('POST /auth/register', () => { const response = await app.inject({ method: 'POST', - url: '/auth/register', + url: '/v1/auth/register', payload: {email: 'ivan@example.com', password: 'secret'}, }); @@ -474,7 +476,7 @@ describe('POST /auth/register', () => { const response = await app.inject({ method: 'POST', - url: '/auth/register', + url: '/v1/auth/register', payload: {email: 'duplicate@example.com', password: 'secret'}, }); @@ -523,7 +525,7 @@ describe('POST /auth/register', () => { const response = await app.inject({ method: 'POST', - url: '/auth/register', + url: '/v1/auth/register', payload: {username: 'judy', secret: 'topsecret'}, }); @@ -548,7 +550,7 @@ describe('POST /auth/register', () => { await app.inject({ method: 'POST', - url: '/auth/register', + url: '/v1/auth/register', payload: {username: 'kate', secret: 'rawpass'}, }); @@ -636,7 +638,7 @@ describe('POST /auth/register', () => { const response = await app.inject({ method: 'POST', - url: '/auth/register', + url: '/v1/auth/register', payload: { email: 'alice@example.com', password: 'p@ssw0rd', @@ -676,7 +678,7 @@ describe('POST /auth/register', () => { const response = await app.inject({ method: 'POST', - url: '/auth/register', + url: '/v1/auth/register', payload: { email: 'bob@example.com', password: 'secret', @@ -705,4 +707,54 @@ describe('POST /auth/register', () => { await app.close(); }); }); + + describe('API variants', () => { + test('should register admin variant endpoint when apiVariants is configured', async () => { + const app = await createAuthApp( + upAuthConfig, + authModels, + pgConfig, + undefined, + { + 'auth.v1.users.unknown.registration': { + variants: ['admin'], + }, + }, + ); + + const response = await app.inject({ + method: 'POST', + url: '/admin/auth/register', + payload: {email: 'alice@example.com', password: 'p@ssw0rd'}, + }); + + expect(response.statusCode).toBe(201); + await app.close(); + }); + + test('should not register admin variant when disabled in apis config', async () => { + const app = await createAuthApp( + upAuthConfig, + authModels, + pgConfig, + { + 'auth.admin.users.unknown.registration': {enabled: false}, + }, + { + 'auth.v1.users.unknown.registration': { + variants: ['admin'], + }, + }, + ); + + const response = await app.inject({ + method: 'POST', + url: '/admin/auth/register', + payload: {email: 'alice@example.com', password: 'p@ssw0rd'}, + }); + + expect(response.statusCode).toBe(404); + await app.close(); + }); + }); }); diff --git a/tests/routes/resend-otp.test.ts b/tests/routes/resend-otp.test.ts index cb7ca7d..9bed664 100644 --- a/tests/routes/resend-otp.test.ts +++ b/tests/routes/resend-otp.test.ts @@ -65,10 +65,11 @@ const pgConfig: DatabaseConfig = { async function createResendOtpApp( authentication: AuthenticationConfig, - action: 'login' | 'register' | 'forgot-password', + action: 'login' | 'register' | 'forgotPassword', models: Record = authModels, dbConfig: DatabaseConfig = pgConfig, apis?: Record, + apiVariants?: Record, ): Promise { const app = Fastify(); const config: AppConfig = { @@ -84,6 +85,7 @@ async function createResendOtpApp( data: {models}, authentication, ...(apis ? {apis} : {}), + ...(apiVariants ? {apiVariants} : {}), }; app.appConfig = config; @@ -124,20 +126,20 @@ async function createResendOtpApp( return app; } -function getPath(action: 'login' | 'register' | 'forgot-password'): string { - if (action === 'login') return '/auth/login/resend/otp'; - if (action === 'register') return '/auth/register/resend/otp'; - return '/auth/forgot-password/resend/otp'; +function getPath(action: 'login' | 'register' | 'forgotPassword'): string { + if (action === 'login') return '/v1/auth/login/resend/otp'; + if (action === 'register') return '/v1/auth/register/resend/otp'; + return '/v1/auth/forgot-password/resend/otp'; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- -const actions: Array<'login' | 'register' | 'forgot-password'> = [ +const actions: Array<'login' | 'register' | 'forgotPassword'> = [ 'login', 'register', - 'forgot-password', + 'forgotPassword', ]; for (const action of actions) { @@ -183,7 +185,13 @@ for (const action of actions) { }); test('should NOT register the route when the API is disabled via apis config', async () => { - const apiKey = `auth.users.all.resend-otp-${action}`; + const actionSuffix = + action === 'login' + ? 'Login' + : action === 'register' + ? 'Register' + : 'ForgotPassword'; + const apiKey = `auth.v1.users.unknown.resendOtp${actionSuffix}`; const app = await createResendOtpApp( upAuthConfig, action, @@ -262,5 +270,67 @@ for (const action of actions) { await app.close(); }); }); + + describe('API variants', () => { + test('should register additional variant endpoint when apiVariants is configured', async () => { + const actionSuffix = + action === 'login' + ? 'Login' + : action === 'register' + ? 'Register' + : 'ForgotPassword'; + const apiKey = `auth.v1.users.unknown.resendOtp${actionSuffix}`; + const app = await createResendOtpApp( + upAuthConfig, + action, + undefined, + undefined, + undefined, + { + [apiKey]: {variants: ['admin']}, + }, + ); + const token = app.jwt.sign({id: 1, email: 'admin@example.com'}); + pgQueryMock.mockResolvedValueOnce({ + rows: [{id: 1, email: 'admin@example.com'}], + rowCount: 1, + }); + const variantPath = path.replace('/v1/', '/admin/'); + const response = await app.inject({ + method: 'POST', + url: variantPath, + headers: {authorization: `Bearer ${token}`}, + payload: {email: 'admin@example.com'}, + }); + expect(response.statusCode).toBe(200); + await app.close(); + }); + + test('should not register variant endpoint when disabled in apis config', async () => { + const actionSuffix = + action === 'login' + ? 'Login' + : action === 'register' + ? 'Register' + : 'ForgotPassword'; + const apiKey = `auth.v1.users.unknown.resendOtp${actionSuffix}`; + const adminKey = `auth.admin.users.unknown.resendOtp${actionSuffix}`; + const app = await createResendOtpApp( + upAuthConfig, + action, + undefined, + undefined, + {[adminKey]: {enabled: false}}, + {[apiKey]: {variants: ['admin']}}, + ); + const variantPath = path.replace('/v1/', '/admin/'); + const response = await app.inject({ + method: 'POST', + url: variantPath, + }); + expect(response.statusCode).toBe(404); + await app.close(); + }); + }); }); } diff --git a/tests/routes/search.test.ts b/tests/routes/search.test.ts index f50e998..8e9a478 100644 --- a/tests/routes/search.test.ts +++ b/tests/routes/search.test.ts @@ -88,7 +88,7 @@ describe('test search api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=alice', + url: '/v1/users/search/name?name_search=alice', }); expect(response.statusCode).toBe(200); @@ -104,7 +104,7 @@ describe('test search api', () => { await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=Alice', + url: '/v1/users/search/name?name_search=Alice', }); expect(pgClientQueryMock).toHaveBeenCalledTimes(4); @@ -121,7 +121,7 @@ describe('test search api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=zzznomatch', + url: '/v1/users/search/name?name_search=zzznomatch', }); expect(response.statusCode).toBe(200); @@ -141,7 +141,7 @@ describe('test search api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=test', + url: '/v1/users/search/name?name_search=test', }); expect(response.statusCode).toBe(200); @@ -155,7 +155,7 @@ describe('test search api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=test', + url: '/v1/users/search/name?name_search=test', }); expect(response.json().message).toBe( @@ -170,7 +170,7 @@ describe('test search api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=test', + url: '/v1/users/search/name?name_search=test', }); expect(response.json().data.pagination).toEqual({ @@ -190,7 +190,7 @@ describe('test search api', () => { await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=al&page=2&limit=15', + url: '/v1/users/search/name?name_search=al&page=2&limit=15', }); expect(pgClientQueryMock).toHaveBeenCalledWith( @@ -206,7 +206,7 @@ describe('test search api', () => { await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=al&page=0&limit=10', + url: '/v1/users/search/name?name_search=al&page=0&limit=10', }); expect(pgClientQueryMock).toHaveBeenCalledWith( @@ -222,7 +222,7 @@ describe('test search api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=bob&page=3&limit=10', + url: '/v1/users/search/name?name_search=bob&page=3&limit=10', }); expect(response.json().data.pagination).toEqual({ @@ -242,7 +242,7 @@ describe('test search api', () => { await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=ali&email_ne=bob@example.com', + url: '/v1/users/search/name?name_search=ali&email_ne=bob@example.com', }); const callArgs = pgClientQueryMock.mock.calls[2]; @@ -256,7 +256,7 @@ describe('test search api', () => { await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=ali&email_eq=alice@example.com', + url: '/v1/users/search/name?name_search=ali&email_eq=alice@example.com', }); const callArgs = pgClientQueryMock.mock.calls[2]; @@ -274,7 +274,7 @@ describe('test search api', () => { await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=al&id_lt=100', + url: '/v1/users/search/name?name_search=al&id_lt=100', }); const callArgs = pgClientQueryMock.mock.calls[2]; @@ -289,7 +289,7 @@ describe('test search api', () => { await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=al&id_gt=0', + url: '/v1/users/search/name?name_search=al&id_gt=0', }); const callArgs = pgClientQueryMock.mock.calls[2]; @@ -304,7 +304,7 @@ describe('test search api', () => { await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=al&id_gte=1', + url: '/v1/users/search/name?name_search=al&id_gte=1', }); const callArgs = pgClientQueryMock.mock.calls[2]; @@ -319,7 +319,7 @@ describe('test search api', () => { await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=al&id_lte=50', + url: '/v1/users/search/name?name_search=al&id_lte=50', }); const callArgs = pgClientQueryMock.mock.calls[2]; @@ -334,7 +334,7 @@ describe('test search api', () => { await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=al&id_in=1,2,3', + url: '/v1/users/search/name?name_search=al&id_in=1,2,3', }); const callArgs = pgClientQueryMock.mock.calls[2]; @@ -351,7 +351,7 @@ describe('test search api', () => { await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=al&id_not_in=10,20,30', + url: '/v1/users/search/name?name_search=al&id_not_in=10,20,30', }); const callArgs = pgClientQueryMock.mock.calls[2]; @@ -370,7 +370,7 @@ describe('test search api', () => { await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=al&orderBy=name', + url: '/v1/users/search/name?name_search=al&orderBy=name', }); const callArgs = pgClientQueryMock.mock.calls[2]; @@ -384,7 +384,7 @@ describe('test search api', () => { await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=al&orderBy=name&orderDir=desc', + url: '/v1/users/search/name?name_search=al&orderBy=name&orderDir=desc', }); const callArgs = pgClientQueryMock.mock.calls[2]; @@ -398,7 +398,7 @@ describe('test search api', () => { await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=al', + url: '/v1/users/search/name?name_search=al', }); const callArgs = pgClientQueryMock.mock.calls[2]; @@ -415,7 +415,7 @@ describe('test search api', () => { // Search by title const byTitle = await fastify.inject({ method: 'GET', - url: '/products/search/title?title_search=rocket', + url: '/v1/products/search/title?title_search=rocket', }); expect(byTitle.statusCode).toBe(200); expect(pgClientQueryMock).toHaveBeenNthCalledWith( @@ -430,7 +430,7 @@ describe('test search api', () => { // Search by description const byDescription = await fastify.inject({ method: 'GET', - url: '/products/search/description?description_search=rocket&title_search=ignored', + url: '/v1/products/search/description?description_search=rocket&title_search=ignored', }); expect(byDescription.statusCode).toBe(200); expect(pgClientQueryMock).toHaveBeenNthCalledWith( @@ -446,12 +446,12 @@ describe('test search api', () => { describe('error handling', () => { test('should return 404 when the search API is disabled via config', async () => { const fastify = await createTestApp(pgConfig, searchableModel, { - 'model.users.name.search': {enabled: false}, + 'model.v1.users.name.search': {enabled: false}, }); const response = await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=test', + url: '/v1/users/search/name?name_search=test', }); expect(response.statusCode).toBe(404); @@ -465,7 +465,7 @@ describe('test search api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=test', + url: '/v1/users/search/name?name_search=test', }); expect(response.statusCode).toBe(500); @@ -483,7 +483,7 @@ describe('test search api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=test', + url: '/v1/users/search/name?name_search=test', }); expect(response.statusCode).toBe(500); @@ -501,7 +501,7 @@ describe('test search api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=test', + url: '/v1/users/search/name?name_search=test', }); expect(response.statusCode).toBe(500); @@ -516,7 +516,7 @@ describe('test search api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=test&limit=0', + url: '/v1/users/search/name?name_search=test&limit=0', }); expect(response.statusCode).toBe(400); @@ -531,7 +531,7 @@ describe('test search api', () => { const response = await fastify.inject({ method: 'GET', - url: '/logs/search/message?message_search=error', + url: '/v1/logs/search/message?message_search=error', }); expect(response.statusCode).toBe(404); @@ -545,7 +545,7 @@ describe('test search api', () => { const response = await fastify.inject({ method: 'GET', - url: '/nonexistent/search/name?name_search=test', + url: '/v1/nonexistent/search/name?name_search=test', }); expect(response.statusCode).toBe(404); @@ -559,7 +559,7 @@ describe('test search api', () => { await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=', + url: '/v1/users/search/name?name_search=', }); const callArgs = pgClientQueryMock.mock.calls[2]; @@ -574,7 +574,7 @@ describe('test search api', () => { await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=alice&name_contains=foo', + url: '/v1/users/search/name?name_search=alice&name_contains=foo', }); const callArgs = pgClientQueryMock.mock.calls[2]; @@ -589,7 +589,7 @@ describe('test search api', () => { describe('authentication', () => { const apisConfig = { - 'model.users.name.search': { + 'model.v1.users.name.search': { enabled: true, authorization: true, }, @@ -606,7 +606,7 @@ describe('test search api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=test', + url: '/v1/users/search/name?name_search=test', }); expect(response.statusCode).toBe(401); @@ -635,7 +635,7 @@ describe('test search api', () => { const response = await fastify.inject({ method: 'GET', - url: '/users/search/name?name_search=alice', + url: '/v1/users/search/name?name_search=alice', headers: { authorization: `Bearer ${token}`, }, @@ -646,4 +646,70 @@ describe('test search api', () => { await fastify.close(); }); }); + + describe('API variants', () => { + test('should register additional variant endpoint when apiVariants is configured', 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, + searchableModel, + undefined, + undefined, + undefined, + { + 'model.v1.users.name.search': { + variants: ['admin'], + }, + }, + ); + + const response = await fastify.inject({ + method: 'GET', + url: '/admin/users/search/name?name_search=alice', + }); + + expect(response.statusCode).toBe(200); + expect(response.json().data.data).toEqual([ + {id: 1, name: 'Alice', email: 'alice@example.com'}, + ]); + + await fastify.close(); + }); + + test('should not register variant endpoint when disabled in apis config', async () => { + const fastify = await createTestApp( + pgConfig, + searchableModel, + { + 'model.admin.users.name.search': { + enabled: false, + }, + }, + undefined, + undefined, + { + 'model.v1.users.name.search': { + variants: ['admin'], + }, + }, + ); + + const response = await fastify.inject({ + method: 'GET', + url: '/admin/users/search/name?name_search=alice', + }); + + expect(response.statusCode).toBe(404); + + await fastify.close(); + }); + }); }); diff --git a/tests/server.test.ts b/tests/server.test.ts index 2d2bf62..021bc90 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -37,8 +37,17 @@ import {Mode} from '@/interfaces'; import {AppConfig} from '@/interfaces/config'; vi.mock('fastify', () => { + let appConfig: AppConfig | undefined; const mockApp = { - register: vi.fn(), + register: vi.fn(async (plugin: unknown) => { + if (typeof plugin === 'function') { + try { + await plugin(mockApp, {}); + } catch { + // plugins may fail in mock context — ignore + } + } + }), addHook: vi.fn(), setErrorHandler: vi.fn(), listen: vi.fn(), @@ -57,6 +66,12 @@ vi.mock('fastify', () => { message, meta, })), + set appConfig(val: AppConfig) { + appConfig = val; + }, + get appConfig(): AppConfig | undefined { + return appConfig; + }, }; return { default: vi.fn(() => mockApp), @@ -284,10 +299,15 @@ describe('Server', () => { it('should register plugins and routes', async () => { await runStart('dev', false, true); - expect(mockApp.register).toHaveBeenCalledTimes(6); + expect(mockApp.register).toHaveBeenCalledTimes(9); + + const prefixCall = mockApp.register.mock.calls.find( + ([, opts]) => opts?.prefix === '/api', + ); + expect(prefixCall).toBeDefined(); expect(migrateDatabase).toHaveBeenCalledWith(mockConfig); - expect(registerRoutes).toHaveBeenCalledWith(mockApp, mockConfig); + expect(registerRoutes).toHaveBeenCalled(); }); it('should skip migration when migrate is false', async () => { @@ -304,7 +324,7 @@ describe('Server', () => { } as unknown as AppConfig; await startServer(disabledSwaggerConfig, 3000, 'prod'); - expect(mockApp.register).toHaveBeenCalledTimes(5); + expect(mockApp.register).toHaveBeenCalledTimes(6); }); it('should not register routes if models are missing/empty', async () => { @@ -318,6 +338,10 @@ describe('Server', () => { expect.any(Object), noModelsConfig, ); + expect(mockApp.register).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({prefix: '/api'}), + ); }); describe('Error Handler', () => { diff --git a/tests/utils/config.test.ts b/tests/utils/config.test.ts index 470d1e3..1524140 100644 --- a/tests/utils/config.test.ts +++ b/tests/utils/config.test.ts @@ -2,7 +2,13 @@ import {describe, expect, it} from 'vitest'; import {AppConfig} from '@/interfaces/config'; -import {getAPIFromUniqueIdentifier, resolveEnvVars} from '@/utils/config'; +import { + buildApiIdentifier, + getAdditionalVariants, + getAPIFromUniqueIdentifier, + parseApiIdentifier, + resolveEnvVars, +} from '@/utils/config'; describe('Config Utilities', () => { describe('resolveEnvVars', () => { @@ -85,7 +91,7 @@ describe('Config Utilities', () => { it('should return the correct custom endpoint config for a valid identifier', () => { const result = getAPIFromUniqueIdentifier( mockConfig as AppConfig, - 'customEndpoints.get_users', + 'custom.v1.all.unknown.get_users', ); expect(result).toEqual(mockConfig.customEndpoints?.get_users); }); @@ -93,7 +99,7 @@ describe('Config Utilities', () => { it('should return null if the first part is not customEndpoints', () => { const result = getAPIFromUniqueIdentifier( mockConfig as AppConfig, - 'model.users.all.getAll', + 'model.v1.users.unknown.getAll', ); expect(result).toBeNull(); }); @@ -101,7 +107,7 @@ describe('Config Utilities', () => { it('should return null if the second part is not all', () => { const result = getAPIFromUniqueIdentifier( mockConfig as AppConfig, - 'customEndpoints.somethingElse.get_users', + 'custom.v1.all.unknown.somethingElse.get_users', ); expect(result).toBeNull(); }); @@ -109,7 +115,7 @@ describe('Config Utilities', () => { it('should return null if the custom endpoint name is not found', () => { const result = getAPIFromUniqueIdentifier( mockConfig as AppConfig, - 'customEndpoints.non_existent', + 'custom.v1.all.unknown.non_existent', ); expect(result).toBeNull(); }); @@ -117,7 +123,7 @@ describe('Config Utilities', () => { it('should return null if customEndpoints is missing in config', () => { const result = getAPIFromUniqueIdentifier( {} as AppConfig, - 'customEndpoints.get_users', + 'custom.v1.all.unknown.get_users', ); expect(result).toBeNull(); }); @@ -127,4 +133,266 @@ describe('Config Utilities', () => { expect(result).toBeNull(); }); }); + + describe('parseApiIdentifier', () => { + it('should parse a valid 5-part identifier', () => { + const result = parseApiIdentifier('aggregate.v1.users.id.getAggregation'); + expect(result).toEqual({ + module: 'aggregate', + variant: 'v1', + model: 'users', + field: 'id', + operation: 'getAggregation', + }); + }); + + it('should parse identifier with different components', () => { + const result = parseApiIdentifier('model.admin.posts.title.search'); + expect(result).toEqual({ + module: 'model', + variant: 'admin', + model: 'posts', + field: 'title', + operation: 'search', + }); + }); + + it('should parse identifier with "default" variant', () => { + const result = parseApiIdentifier( + 'aggregate.default.users.id.getAggregation', + ); + expect(result).toEqual({ + module: 'aggregate', + variant: 'default', + model: 'users', + field: 'id', + operation: 'getAggregation', + }); + }); + + it('should return null for identifier with less than 5 parts', () => { + expect(parseApiIdentifier('aggregate.v1.users.id')).toBeNull(); + expect(parseApiIdentifier('aggregate.v1.users')).toBeNull(); + expect(parseApiIdentifier('aggregate.v1')).toBeNull(); + expect(parseApiIdentifier('aggregate')).toBeNull(); + }); + + it('should return null for identifier with more than 5 parts', () => { + expect( + parseApiIdentifier('aggregate.v1.users.id.getAggregation.extra'), + ).toBeNull(); + }); + + it('should return null for empty string', () => { + expect(parseApiIdentifier('')).toBeNull(); + }); + + it('should handle identifiers with special field names', () => { + expect(parseApiIdentifier('model.v1.users.unknown.getAll')).toEqual({ + module: 'model', + variant: 'v1', + model: 'users', + field: 'unknown', + operation: 'getAll', + }); + + expect(parseApiIdentifier('custom.v1.all.unknown.myEndpoint')).toEqual({ + module: 'custom', + variant: 'v1', + model: 'all', + field: 'unknown', + operation: 'myEndpoint', + }); + }); + }); + + describe('getAdditionalVariants', () => { + it('should return variants array when identifier exists in apiVariants', () => { + const config: Partial = { + apiVariants: { + 'aggregate.default.users.id.getAggregation': { + variants: ['admin', 'public'], + }, + }, + }; + const result = getAdditionalVariants( + config as AppConfig, + 'aggregate.default.users.id.getAggregation', + ); + expect(result).toEqual(['admin', 'public']); + }); + + it('should return single variant in array', () => { + const config: Partial = { + apiVariants: { + 'aggregate.default.posts.views.getAggregation': { + variants: ['admin'], + }, + }, + }; + const result = getAdditionalVariants( + config as AppConfig, + 'aggregate.default.posts.views.getAggregation', + ); + expect(result).toEqual(['admin']); + }); + + it('should return empty array when identifier does not exist', () => { + const config: Partial = { + apiVariants: { + 'aggregate.default.users.id.getAggregation': { + variants: ['admin'], + }, + }, + }; + const result = getAdditionalVariants( + config as AppConfig, + 'aggregate.default.posts.id.getAggregation', + ); + expect(result).toEqual([]); + }); + + it('should return empty array when apiVariants is undefined', () => { + const config: Partial = {}; + const result = getAdditionalVariants( + config as AppConfig, + 'aggregate.default.users.id.getAggregation', + ); + expect(result).toEqual([]); + }); + + it('should return empty array when apiVariants is empty object', () => { + const config: Partial = { + apiVariants: {}, + }; + const result = getAdditionalVariants( + config as AppConfig, + 'aggregate.default.users.id.getAggregation', + ); + expect(result).toEqual([]); + }); + + it('should handle multiple different identifiers', () => { + const config: Partial = { + apiVariants: { + 'aggregate.default.users.id.getAggregation': { + variants: ['admin'], + }, + 'aggregate.default.posts.views.getAggregation': { + variants: ['public', 'readonly'], + }, + 'model.default.users.unknown.search': { + variants: ['v2'], + }, + }, + }; + + expect( + getAdditionalVariants( + config as AppConfig, + 'aggregate.default.users.id.getAggregation', + ), + ).toEqual(['admin']); + + expect( + getAdditionalVariants( + config as AppConfig, + 'aggregate.default.posts.views.getAggregation', + ), + ).toEqual(['public', 'readonly']); + + expect( + getAdditionalVariants( + config as AppConfig, + 'model.default.users.unknown.search', + ), + ).toEqual(['v2']); + }); + }); + + describe('buildApiIdentifier', () => { + it('should build a valid identifier from components', () => { + const result = buildApiIdentifier( + 'aggregate', + 'v1', + 'users', + 'id', + 'getAggregation', + ); + expect(result).toBe('aggregate.v1.users.id.getAggregation'); + }); + + it('should build identifier with admin variant', () => { + const result = buildApiIdentifier( + 'aggregate', + 'admin', + 'users', + 'id', + 'getAggregation', + ); + expect(result).toBe('aggregate.admin.users.id.getAggregation'); + }); + + it('should build identifier with default variant', () => { + const result = buildApiIdentifier( + 'aggregate', + 'default', + 'posts', + 'views', + 'getAggregation', + ); + expect(result).toBe('aggregate.default.posts.views.getAggregation'); + }); + + it('should build identifier for model module', () => { + const result = buildApiIdentifier( + 'model', + 'v1', + 'users', + 'unknown', + 'getAll', + ); + expect(result).toBe('model.v1.users.unknown.getAll'); + }); + + it('should build identifier for custom endpoints', () => { + const result = buildApiIdentifier( + 'custom', + 'v1', + 'all', + 'unknown', + 'myEndpoint', + ); + expect(result).toBe('custom.v1.all.unknown.myEndpoint'); + }); + + it('should build identifier for auth module', () => { + const result = buildApiIdentifier( + 'auth', + 'v1', + 'users', + 'unknown', + 'login', + ); + expect(result).toBe('auth.v1.users.unknown.login'); + }); + + it('should handle empty strings in components', () => { + const result = buildApiIdentifier('', '', '', '', ''); + expect(result).toBe('....'); + }); + + it('should build correct format regardless of component values', () => { + const result = buildApiIdentifier( + 'module123', + 'variant-name', + 'model_name', + 'field.name', + 'operation_1', + ); + expect(result).toBe( + 'module123.variant-name.model_name.field.name.operation_1', + ); + }); + }); }); diff --git a/tests/validators/config.test.ts b/tests/validators/config.test.ts index 1310a4c..2c89e38 100644 --- a/tests/validators/config.test.ts +++ b/tests/validators/config.test.ts @@ -2175,7 +2175,7 @@ describe('validateInvalidCustomEndpointsConfig', () => { }, }, apis: { - 'customEndpoints.test': { + 'custom.v1.all.unknown.test': { webhooks: [ { url: 'invalid', @@ -2187,7 +2187,7 @@ describe('validateInvalidCustomEndpointsConfig', () => { }, }, expected: - '/apis/customEndpoints.test/webhooks/0/url must match pattern "^https?:\\/\\/"', + '/apis/custom.v1.all.unknown.test/webhooks/0/url must match pattern "^https?:\\/\\/"', }, { name: 'data field type is not array', @@ -2205,7 +2205,7 @@ describe('validateInvalidCustomEndpointsConfig', () => { }, }, apis: { - 'customEndpoints.test': { + 'custom.v1.all.unknown.test': { webhooks: [ { url: 'https://example.com', @@ -2216,7 +2216,8 @@ describe('validateInvalidCustomEndpointsConfig', () => { }, }, }, - expected: '/apis/customEndpoints.test/webhooks/0/data must be array', + expected: + '/apis/custom.v1.all.unknown.test/webhooks/0/data must be array', }, { name: 'data field is empty array', @@ -2234,7 +2235,7 @@ describe('validateInvalidCustomEndpointsConfig', () => { }, }, apis: { - 'customEndpoints.test': { + 'custom.v1.all.unknown.test': { webhooks: [ { url: 'https://example.com', @@ -2246,7 +2247,7 @@ describe('validateInvalidCustomEndpointsConfig', () => { }, }, expected: - '/apis/customEndpoints.test/webhooks/0/data must NOT have fewer than 1 items', + '/apis/custom.v1.all.unknown.test/webhooks/0/data must NOT have fewer than 1 items', }, { name: 'data field contains invalid value', @@ -2264,7 +2265,7 @@ describe('validateInvalidCustomEndpointsConfig', () => { }, }, apis: { - 'customEndpoints.test': { + 'custom.v1.all.unknown.test': { webhooks: [ { url: 'https://example.com', @@ -2276,7 +2277,7 @@ describe('validateInvalidCustomEndpointsConfig', () => { }, }, expected: - '/apis/customEndpoints.test/webhooks/0/data/1 must be equal to one of the allowed values', + '/apis/custom.v1.all.unknown.test/webhooks/0/data/1 must be equal to one of the allowed values', }, { name: 'triggerOnRequest is not a boolean', @@ -2294,7 +2295,7 @@ describe('validateInvalidCustomEndpointsConfig', () => { }, }, apis: { - 'customEndpoints.test': { + 'custom.v1.all.unknown.test': { webhooks: [ { url: 'https://example.com', @@ -2306,7 +2307,7 @@ describe('validateInvalidCustomEndpointsConfig', () => { }, }, expected: - '/apis/customEndpoints.test/webhooks/0/triggerOnRequest must be boolean', + '/apis/custom.v1.all.unknown.test/webhooks/0/triggerOnRequest must be boolean', }, { name: 'triggerOnResponse is not a boolean', @@ -2324,7 +2325,7 @@ describe('validateInvalidCustomEndpointsConfig', () => { }, }, apis: { - 'customEndpoints.test': { + 'custom.v1.all.unknown.test': { webhooks: [ { url: 'https://example.com', @@ -2337,7 +2338,7 @@ describe('validateInvalidCustomEndpointsConfig', () => { }, }, expected: - '/apis/customEndpoints.test/webhooks/0/triggerOnResponse must be boolean', + '/apis/custom.v1.all.unknown.test/webhooks/0/triggerOnResponse must be boolean', }, { name: 'triggerOnResponse or triggerOnRequest needs to be true, both cannot be false', @@ -2355,7 +2356,7 @@ describe('validateInvalidCustomEndpointsConfig', () => { }, }, apis: { - 'customEndpoints.test': { + 'custom.v1.all.unknown.test': { webhooks: [ { url: 'https://example.com', @@ -2368,7 +2369,7 @@ describe('validateInvalidCustomEndpointsConfig', () => { }, }, expected: - 'apis/customEndpoints.test/webhooks/0: webhook must have at least one of triggerOnRequest or triggerOnResponse', + 'apis/custom.v1.all.unknown.test/webhooks/0: webhook must have at least one of triggerOnRequest or triggerOnResponse', }, { name: 'custom endpoint with invalid validation schema', @@ -2520,7 +2521,7 @@ describe('validateValidCustomEndpointsConfig', () => { }, }, apis: { - 'customEndpoints.sample_query': { + 'custom.v1.all.unknown.sample_query': { webhooks: [ { url: 'https://example.com', @@ -2545,7 +2546,7 @@ describe('validateValidCustomEndpointsConfig', () => { }, }, apis: { - 'customEndpoints.sample_query': { + 'custom.v1.all.unknown.sample_query': { webhooks: [ { url: 'https://example.com', @@ -2570,7 +2571,7 @@ describe('validateValidCustomEndpointsConfig', () => { }, }, apis: { - 'customEndpoints.sample_query': { + 'custom.v1.all.unknown.sample_query': { webhooks: [ { url: 'https://example.com', @@ -2774,6 +2775,328 @@ describe('validateRateLimitConfig', () => { }); }); +// ----- Variant Config Tests ----- + +describe('validateVariantConfig', () => { + it.each([ + { + name: 'empty string (too short)', + patch: {dangerouslyOverrideDefaultVariant: ''}, + expected: + '/application/dangerouslyOverrideDefaultVariant must NOT have fewer than 1 characters', + }, + { + name: 'string longer than 25 characters', + patch: { + dangerouslyOverrideDefaultVariant: 'abcdefghijklmnopqrstuvwxyz', + }, + expected: + '/application/dangerouslyOverrideDefaultVariant must NOT have more than 25 characters', + }, + { + name: 'contains spaces', + patch: {dangerouslyOverrideDefaultVariant: 'my variant'}, + expected: + '/application/dangerouslyOverrideDefaultVariant must match pattern', + }, + { + name: 'contains special characters', + patch: {dangerouslyOverrideDefaultVariant: 'variant@123'}, + expected: + '/application/dangerouslyOverrideDefaultVariant must match pattern', + }, + { + name: 'boolean instead of string', + patch: { + dangerouslyOverrideDefaultVariant: + true as unknown as typeof validBaseConfig.application, + }, + expected: '/application/dangerouslyOverrideDefaultVariant must be string', + }, + ])('Scenario: $name . should throw error', ({patch, expected}) => { + const config = { + ...validBaseConfig, + application: { + ...validBaseConfig.application, + ...patch, + }, + }; + + expect(() => validateConfig(config as unknown as AppConfig)).toThrow( + expected, + ); + }); + + it.each([ + { + name: 'simple variant', + patch: {dangerouslyOverrideDefaultVariant: 'v1'}, + }, + { + name: 'variant with hyphens', + patch: {dangerouslyOverrideDefaultVariant: 'experimental-v2'}, + }, + { + name: 'variant with underscore', + patch: {dangerouslyOverrideDefaultVariant: 'test_variant'}, + }, + { + name: 'max length variant', + patch: { + dangerouslyOverrideDefaultVariant: 'abcdefghijklmnopqrstuvwxy', + }, + }, + { + name: 'variant with numbers', + patch: {dangerouslyOverrideDefaultVariant: 'build-42'}, + }, + { + name: 'variant with uppercase', + patch: {dangerouslyOverrideDefaultVariant: 'Variant-V3'}, + }, + ])('Scenario: $name . should return', ({patch}) => { + const config = { + ...validBaseConfig, + application: { + ...validBaseConfig.application, + ...patch, + }, + }; + + expect(validateConfig(config as unknown as AppConfig)).toEqual(config); + }); + + it('should work without dangerouslyOverrideDefaultVariant (optional)', () => { + const config = { + ...validBaseConfig, + }; + + expect(validateConfig(config as unknown as AppConfig)).toEqual(config); + }); +}); + +// ----- Api Variants Config Tests ----- + +describe('validateApiVariantsConfig', () => { + it.each([ + { + name: 'missing variants property', + patch: { + apiVariants: { + 'aggregate.v1.users.id.getAggregation': {} as unknown as { + variants: string[]; + }, + }, + }, + expected: + "/apiVariants/aggregate.v1.users.id.getAggregation must have required property 'variants'", + }, + { + name: 'variants is a string instead of array', + patch: { + apiVariants: { + 'aggregate.v1.users.id.getAggregation': { + variants: 'admin', + }, + }, + }, + expected: + '/apiVariants/aggregate.v1.users.id.getAggregation/variants must be array', + }, + { + name: 'empty variants array', + patch: { + apiVariants: { + 'aggregate.v1.users.id.getAggregation': { + variants: [], + }, + }, + }, + expected: + '/apiVariants/aggregate.v1.users.id.getAggregation/variants must NOT have fewer than 1 items', + }, + { + name: 'variant name longer than 25 characters', + patch: { + apiVariants: { + 'aggregate.v1.users.id.getAggregation': { + variants: ['abcdefghijklmnopqrstuvwxyz'], + }, + }, + }, + expected: + '/apiVariants/aggregate.v1.users.id.getAggregation/variants/0 must NOT have more than 25 characters', + }, + { + name: 'variant contains spaces', + patch: { + apiVariants: { + 'aggregate.v1.users.id.getAggregation': { + variants: ['my variant'], + }, + }, + }, + expected: + '/apiVariants/aggregate.v1.users.id.getAggregation/variants/0 must match pattern', + }, + { + name: 'variant contains special characters', + patch: { + apiVariants: { + 'aggregate.v1.users.id.getAggregation': { + variants: ['variant@123'], + }, + }, + }, + expected: + '/apiVariants/aggregate.v1.users.id.getAggregation/variants/0 must match pattern', + }, + { + name: 'variant as boolean instead of string', + patch: { + apiVariants: { + 'aggregate.v1.users.id.getAggregation': { + variants: [true], + }, + }, + }, + expected: + '/apiVariants/aggregate.v1.users.id.getAggregation/variants/0 must be string', + }, + { + name: 'duplicate variant names', + patch: { + apiVariants: { + 'aggregate.v1.users.id.getAggregation': { + variants: ['admin', 'admin'], + }, + }, + }, + expected: + '/apiVariants/aggregate.v1.users.id.getAggregation/variants must NOT have duplicate items', + }, + { + name: 'empty variant string in array', + patch: { + apiVariants: { + 'aggregate.v1.users.id.getAggregation': { + variants: [''], + }, + }, + }, + expected: + '/apiVariants/aggregate.v1.users.id.getAggregation/variants/0 must NOT have fewer than 1 characters', + }, + { + name: 'extra unknown property', + patch: { + apiVariants: { + 'aggregate.v1.users.id.getAggregation': { + variants: ['admin'], + extraField: 'should not be allowed', + }, + }, + }, + expected: + '/apiVariants/aggregate.v1.users.id.getAggregation must NOT have additional properties', + }, + { + name: 'invalid key pattern (spaces)', + patch: { + apiVariants: { + 'invalid key': { + variants: ['admin'], + }, + }, + }, + expected: '/apiVariants must NOT have additional properties', + }, + ])('Scenario: $name . should throw error', ({patch, expected}) => { + const config = { + ...validBaseConfig, + ...patch, + }; + + expect(() => validateConfig(config as unknown as AppConfig)).toThrow( + expected, + ); + }); + + it.each([ + { + name: 'single valid api variant with one variant', + patch: { + apiVariants: { + 'aggregate.v1.users.id.getAggregation': { + variants: ['admin'], + }, + }, + }, + }, + { + name: 'variant with hyphens and underscores', + patch: { + apiVariants: { + 'model.v1.posts.id.index': { + variants: ['experimental_v2'], + }, + }, + }, + }, + { + name: 'max length variant', + patch: { + apiVariants: { + 'aggregate.v1.users.id.getAggregation': { + variants: ['abcdefghijklmnopqrstuvwxy'], + }, + }, + }, + }, + { + name: 'multiple variants per api', + patch: { + apiVariants: { + 'aggregate.v1.users.id.getAggregation': { + variants: ['admin', 'v1', 'beta-2'], + }, + }, + }, + }, + { + name: 'multiple api variants entries', + patch: { + apiVariants: { + 'model.v1.users.id.index': { + variants: ['v1'], + }, + 'aggregate.v1.users.id.getAggregation': { + variants: ['admin'], + }, + 'model.v1.posts.id.search': { + variants: ['beta-3'], + }, + }, + }, + }, + ])('Scenario: $name . should return', ({patch}) => { + const config = { + ...validBaseConfig, + ...patch, + }; + + expect(validateConfig(config as unknown as AppConfig)).toEqual(config); + }); + + it('should work without apiVariants (optional)', () => { + const config = { + ...validBaseConfig, + }; + + expect(validateConfig(config as unknown as AppConfig)).toEqual(config); + }); +}); + // ----- Cache DB Config Tests ----- describe('validateCacheDbConfig', () => { @@ -2917,19 +3240,19 @@ describe('validateInvalidmodelConfig', () => { { name: 'invalid webhook for model', patch: { - 'aggregate.users.id.getAggregation': 'invalid', + 'aggregate.v1.users.id.getAggregation': 'invalid', }, - expected: '/apis/aggregate.users.id.getAggregation must be object', + expected: '/apis/aggregate.v1.users.id.getAggregation must be object', }, { name: 'invalid webhook conf', patch: { - 'aggregate.users.id.getAggregation': { + 'aggregate.v1.users.id.getAggregation': { webhooks: 'invalid', }, }, expected: - '/apis/aggregate.users.id.getAggregation/webhooks must be array', + '/apis/aggregate.v1.users.id.getAggregation/webhooks must be array', }, { name: 'invalid api key format', @@ -2950,7 +3273,7 @@ describe('validateInvalidmodelConfig', () => { { name: 'invalid data response cannot be used when triggerOnRequest is true', patch: { - 'aggregate.users.id.getAggregation': { + 'aggregate.v1.users.id.getAggregation': { webhooks: [ { url: 'https://google.com', @@ -2962,12 +3285,12 @@ describe('validateInvalidmodelConfig', () => { }, }, expected: - 'apis/aggregate.users.id.getAggregation/webhooks/0: data response cannot be used when triggerOnRequest is true', + 'apis/aggregate.v1.users.id.getAggregation/webhooks/0: data response cannot be used when triggerOnRequest is true', }, { name: 'custom endpoint key not found', patch: { - 'customEndpoints.nonexistent': { + 'custom.v1.all.unknown.nonexistent': { webhooks: [ { url: 'https://example.com', @@ -2977,12 +3300,13 @@ describe('validateInvalidmodelConfig', () => { ], }, }, - expected: 'apis/customEndpoints.nonexistent: custom endpoint not found', + expected: + 'apis/custom.v1.all.unknown.nonexistent: custom endpoint not found', }, { name: 'custom endpoint key invalid format', patch: { - 'customEndpoints.test.extra': { + 'custom.v1.all.unknown.test.extra': { webhooks: [ { url: 'https://example.com', @@ -2992,7 +3316,7 @@ describe('validateInvalidmodelConfig', () => { ], }, }, - expected: 'apis/customEndpoints.test.extra: invalid key format', + expected: 'apis/custom.v1.all.unknown.test.extra: invalid key format', }, ])('Scenario: $name . should throw error', ({patch, expected}) => { const config = { @@ -3011,7 +3335,7 @@ describe('validateValidmodelConfig', () => { { name: 'valid model', patch: { - 'aggregate.users.id.getAggregation': { + 'aggregate.v1.users.id.getAggregation': { webhooks: [ { url: 'https://google.com', @@ -3021,7 +3345,7 @@ describe('validateValidmodelConfig', () => { }, ], }, - 'model.users.id.delete': { + 'model.v1.users.id.delete': { webhooks: [ { url: 'https://google.com', @@ -3031,7 +3355,7 @@ describe('validateValidmodelConfig', () => { }, ], }, - 'model.users.id.edit': { + 'model.v1.users.id.edit': { webhooks: [ { url: 'https://google.com', @@ -3041,7 +3365,7 @@ describe('validateValidmodelConfig', () => { }, ], }, - 'model.users.all.getAll': { + 'model.v1.users.unknown.getAll': { webhooks: [ { url: 'https://google.com', @@ -3051,7 +3375,7 @@ describe('validateValidmodelConfig', () => { }, ], }, - 'model.users.id.index': { + 'model.v1.users.id.index': { webhooks: [ { url: 'https://google.com', @@ -3061,7 +3385,7 @@ describe('validateValidmodelConfig', () => { }, ], }, - 'model.users.all.insert': { + 'model.v1.users.unknown.insert': { webhooks: [ { url: 'https://google.com', @@ -3071,7 +3395,7 @@ describe('validateValidmodelConfig', () => { }, ], }, - 'model.users.id.search': { + 'model.v1.users.id.search': { webhooks: [ { url: 'https://google.com', @@ -4084,7 +4408,7 @@ describe('validateValidSspConfig', () => { const config = { ...validBaseConfig, apis: { - 'model.posts.all.getAll': { + 'model.v1.posts.unknown.getAll': { serverParams: patch.serverParams, }, }, @@ -4100,18 +4424,20 @@ describe('validateInvalidAuthorizationConfig', () => { { name: 'invalid authorization config', patch: {authorization: 'wrong'}, - expected: 'model.posts.all.getAll/authorization must be boolean', + expected: + '/apis/model.v1.posts.unknown.getAll/authorization must be boolean', }, { name: 'invalid authorization config', patch: {authorization: null}, - expected: 'model.posts.all.getAll/authorization must be boolean', + expected: + '/apis/model.v1.posts.unknown.getAll/authorization must be boolean', }, ])('Scenario: $name . should throw error', ({patch, expected}) => { const config = { ...validBaseConfig, apis: { - 'model.posts.all.getAll': { + 'model.v1.posts.unknown.getAll': { authorization: patch.authorization, }, }, @@ -4129,7 +4455,7 @@ describe('validateInvalidAuthorizationConfig', () => { name: 'authorization is enabled when authentication is disabled', patch: {authorization: true}, expected: - 'apis/model.posts.all.getAll/authorization: authorization is only allowed when auth is enabled', + 'apis/model.v1.posts.unknown.getAll/authorization: authorization is only allowed when auth is enabled', }, ])('Scenario: $name . should throw error', ({patch, expected}) => { const config = { @@ -4142,7 +4468,7 @@ describe('validateInvalidAuthorizationConfig', () => { }, }, apis: { - 'model.posts.all.getAll': { + 'model.v1.posts.unknown.getAll': { authorization: patch.authorization, }, }, @@ -4175,7 +4501,7 @@ describe('validateValidAuthorizationConfig', () => { }, }, apis: { - 'model.posts.all.getAll': { + 'model.v1.posts.unknown.getAll': { authorization: patch.authorization, }, },