diff --git a/example_config.json b/example_config.json index 7a31da0..636960b 100644 --- a/example_config.json +++ b/example_config.json @@ -32,25 +32,26 @@ "name": "id", "type": "integer", "primaryKey": true, - "supportedOperation": ["editable", "deletable"] + "supportedOperations": ["editable", "deletable"] }, { "name": "email", "type": "string", "unique": true, "nullable": false, - "supportedOperation": ["searchable"] + "supportedOperations": ["searchable", "equal"] }, { "name": "name", "type": "string", "nullable": true, - "supportedOperation": ["searchable"] + "supportedOperations": ["searchable", "sortable"] }, { "name": "is_active", "type": "boolean", - "default": true + "default": true, + "supportedOperations": ["equal"] } ], "indexes": [ @@ -72,12 +73,14 @@ { "name": "id", "type": "integer", - "primaryKey": true + "primaryKey": true, + "supportedOperations": ["editable", "deletable"] }, { "name": "title", "type": "string", - "nullable": false + "nullable": false, + "supportedOperations": ["searchable", "sortable"] }, { "name": "body", @@ -87,11 +90,14 @@ { "name": "user_id", "type": "integer", - "nullable": false + "nullable": false, + "supportedOperations": ["equal", "oneOf"], + "supportedAggregation": ["count", "frequency"] }, { "name": "created_at", - "type": "datetime" + "type": "datetime", + "supportedOperations": ["lessThan", "greaterThan", "sortable"] } ], "indexes": [ diff --git a/src/database/fk-creator.ts b/src/database/fk-creator.ts index 5fe7b2a..789d142 100644 --- a/src/database/fk-creator.ts +++ b/src/database/fk-creator.ts @@ -33,8 +33,9 @@ export async function createForeignKeys( let fkExists = false; if (engine === 'pg') { - const query = SQL`SELECT EXISTS (SELECT FROM information_schema.table_constraints WHERE constraint_name = ${fk.name} AND constraint_type = 'FOREIGN KEY')`; - const res = await db.query(query.sql, query.values); + const query = + "SELECT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = $1 AND constraint_type = 'FOREIGN KEY')"; + const res = await db.query(query, [fk.name]); fkExists = res[0].exists; } else { // SQLite doesn't support ALTER TABLE ADD CONSTRAINT for FKs. diff --git a/src/database/index-creator.ts b/src/database/index-creator.ts index ea9894b..ac8ef06 100644 --- a/src/database/index-creator.ts +++ b/src/database/index-creator.ts @@ -37,12 +37,13 @@ export async function createIndexes( // Check if index already exists let indexExists = false; if (engine === 'pg') { - const query = SQL`SELECT EXISTS (SELECT FROM pg_indexes WHERE schemaname = 'public' AND indexname = ${index.name})`; - const res = await db.query(query.sql, query.values); + const query = + "SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE schemaname = 'public' AND indexname = $1)"; + const res = await db.query(query, [index.name]); indexExists = res[0].exists; } else { - const query = SQL`SELECT count(*) as count FROM sqlite_master WHERE type='index' AND name=${index.name}`; - const res = await db.query(query.sql, query.values); + const query = "SELECT count(*) as count FROM sqlite_master WHERE type='index' AND name=$1"; + const res = await db.query(query, [index.name]); indexExists = res[0].count > 0; } diff --git a/src/database/table-creator.ts b/src/database/table-creator.ts index 2b1624e..c788d45 100644 --- a/src/database/table-creator.ts +++ b/src/database/table-creator.ts @@ -25,7 +25,7 @@ export async function createTables( let tableExists = false; if (engine === 'pg') { const res = await db.query( - "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1)", + "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1)", [model.name] ); tableExists = res[0].exists; diff --git a/src/routes/aggregate.ts b/src/routes/aggregate.ts new file mode 100644 index 0000000..db7f70b --- /dev/null +++ b/src/routes/aggregate.ts @@ -0,0 +1,46 @@ +import { FastifyInstance } from 'fastify'; + +import { ModelConfig } from '../schema/config'; +import { helloWorldResponseSchema } from './schema-helpers'; + +/** + * Register AGGREGATE routes for fields with supportedAggregation. + * + * For each model, for each field with non-empty supportedAggregation, creates: + * GET /{model}/aggregation/{columnName} + * + * Query params: + * - operations (string) — comma-separated list of aggregation operations to perform + */ +export function registerAggregateRoutes(app: FastifyInstance, models: ModelConfig[]): void { + for (const model of models) { + const aggregatableFields = model.fields.filter( + (f) => f.supportedAggregation && f.supportedAggregation.length > 0 + ); + + for (const field of aggregatableFields) { + const operations = field.supportedAggregation!; + + app.get( + `/${model.name}/aggregation/${field.name}`, + { + schema: { + description: `Get aggregation data for ${field.name} in ${model.name}`, + tags: [model.name], + querystring: { + type: 'object', + properties: { + operations: { + type: 'string', + description: `Comma-separated list of operations to perform: ${operations.join(', ')}`, + }, + }, + }, + response: helloWorldResponseSchema, + }, + }, + async () => ({ message: 'hello world' }) + ); + } + } +} diff --git a/src/routes/delete.ts b/src/routes/delete.ts new file mode 100644 index 0000000..e1a61a1 --- /dev/null +++ b/src/routes/delete.ts @@ -0,0 +1,46 @@ +import { FastifyInstance } from 'fastify'; + +import { ModelConfig } from '../schema/config'; +import { mapDataTypeToJsonSchema, helloWorldResponseSchema } from './schema-helpers'; + +/** + * Register DELETE routes for deletable fields. + * + * For each model, for each field with 'deletable' in supportedOperations, creates: + * DELETE /{model}/{columnName}/:value + * + * Path params: the column value identifying the record to delete. + */ +export function registerDeleteRoutes(app: FastifyInstance, models: ModelConfig[]): void { + for (const model of models) { + const deletableFields = model.fields.filter((f) => + f.supportedOperations?.includes('deletable') + ); + + for (const field of deletableFields) { + const paramSchema = mapDataTypeToJsonSchema(field.type); + + app.delete( + `/${model.name}/${field.name}/:${field.name}`, + { + schema: { + description: `Delete ${model.name} record by ${field.name}`, + tags: [model.name], + params: { + type: 'object', + properties: { + [field.name]: { + ...paramSchema, + description: `The ${field.name} value identifying the record to delete`, + }, + }, + required: [field.name], + }, + response: helloWorldResponseSchema, + }, + }, + async () => ({ message: 'hello world' }) + ); + } + } +} diff --git a/src/routes/edit.ts b/src/routes/edit.ts new file mode 100644 index 0000000..4cbc2cd --- /dev/null +++ b/src/routes/edit.ts @@ -0,0 +1,59 @@ +import { FastifyInstance } from 'fastify'; + +import { ModelConfig } from '../schema/config'; +import { mapDataTypeToJsonSchema, helloWorldResponseSchema } from './schema-helpers'; + +/** + * Register EDIT routes for editable fields. + * + * For each model, for each field with 'editable' in supportedOperations, creates: + * PUT /{model}/{columnName}/:value + * + * Path params: the column value identifying the record to edit. + * Body: all other fields as optional properties for updating. + */ +export function registerEditRoutes(app: FastifyInstance, models: ModelConfig[]): void { + for (const model of models) { + const editableFields = model.fields.filter((f) => f.supportedOperations?.includes('editable')); + + for (const field of editableFields) { + const paramSchema = mapDataTypeToJsonSchema(field.type); + + // Body contains all other fields as optional update targets + const bodyProperties: Record = {}; + for (const otherField of model.fields) { + if (otherField.name === field.name) continue; + bodyProperties[otherField.name] = { + ...mapDataTypeToJsonSchema(otherField.type), + description: `Updated value for ${otherField.name}`, + }; + } + + app.put( + `/${model.name}/${field.name}/:${field.name}`, + { + schema: { + description: `Edit ${model.name} record by ${field.name}`, + tags: [model.name], + params: { + type: 'object', + properties: { + [field.name]: { + ...paramSchema, + description: `The ${field.name} value identifying the record to edit`, + }, + }, + required: [field.name], + }, + body: { + type: 'object', + properties: bodyProperties, + }, + response: helloWorldResponseSchema, + }, + }, + async () => ({ message: 'hello world' }) + ); + } + } +} diff --git a/src/routes/get-all.ts b/src/routes/get-all.ts new file mode 100644 index 0000000..fc393c0 --- /dev/null +++ b/src/routes/get-all.ts @@ -0,0 +1,58 @@ +import { FastifyInstance } from 'fastify'; + +import { ModelConfig } from '../schema/config'; +import { + buildFilterQueryProperties, + buildSortQueryProperties, + paginationQueryProperties, + helloWorldResponseSchema, +} from './schema-helpers'; + +/** + * Register GET_ALL routes for listing records (table-level). + * + * For each model that has fields, creates: + * GET /{model}/ + * + * Query params: + * - Filter params for ALL fields based on their supportedOperations + * - orderBy / orderDir for sortable fields + * - page / limit for pagination + */ +export function registerGetAllRoutes(app: FastifyInstance, models: ModelConfig[]): void { + for (const model of models) { + if (model.fields.length === 0) continue; + + const queryProperties: Record = {}; + + // Add filter params for each field based on its supportedOperations + for (const field of model.fields) { + Object.assign(queryProperties, buildFilterQueryProperties(field)); + } + + // Add sort params for sortable fields + const sortableFields = model.fields + .filter((f) => f.supportedOperations?.includes('sortable')) + .map((f) => f.name); + Object.assign(queryProperties, buildSortQueryProperties(sortableFields)); + + // Add pagination + Object.assign(queryProperties, paginationQueryProperties); + + app.get( + `/${model.name}/`, + { + schema: { + description: `Get all ${model.name} records`, + tags: [model.name], + querystring: { + type: 'object', + properties: queryProperties, + }, + response: helloWorldResponseSchema, + }, + }, + async () => ({ message: 'hello world' }) + ); + } +} diff --git a/src/routes/index-route.ts b/src/routes/index-route.ts new file mode 100644 index 0000000..6e9e7c7 --- /dev/null +++ b/src/routes/index-route.ts @@ -0,0 +1,67 @@ +import { FastifyInstance } from 'fastify'; + +import { ModelConfig } from '../schema/config'; +import { + mapDataTypeToJsonSchema, + buildFilterQueryProperties, + paginationQueryProperties, + helloWorldResponseSchema, +} from './schema-helpers'; + +/** + * Register INDEX routes for primaryKey fields. + * + * For each model, for each field with primaryKey: true, creates: + * GET /{model}/{columnName}/:value + * + * Includes filter query params based on the field's supportedOperations. + * Includes pagination if the field is not unique. + */ +export function registerIndexRoutes(app: FastifyInstance, models: ModelConfig[]): void { + for (const model of models) { + const pkFields = model.fields.filter((f) => f.primaryKey); + + for (const field of pkFields) { + const paramSchema = mapDataTypeToJsonSchema(field.type); + + // Build filter query params from the field's supported operations + const filterProps = buildFilterQueryProperties(field); + const queryProperties: Record = { + ...filterProps, + }; + + // Primary keys are always unique, so no pagination needed. + // If we ever support non-PK indexable fields, add pagination for non-unique ones. + if (!field.unique && !field.primaryKey) { + Object.assign(queryProperties, paginationQueryProperties); + } + + const schema: Record = { + description: `Get ${model.name} record(s) by ${field.name}`, + tags: [model.name], + params: { + type: 'object', + properties: { + [field.name]: { + ...paramSchema, + description: `The ${field.name} value to look up`, + }, + }, + required: [field.name], + }, + response: helloWorldResponseSchema, + }; + + if (Object.keys(queryProperties).length > 0) { + schema.querystring = { + type: 'object', + properties: queryProperties, + }; + } + + app.get(`/${model.name}/${field.name}/:${field.name}`, { schema }, async () => ({ + message: 'hello world', + })); + } + } +} diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..1603d58 --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1,33 @@ +import { FastifyInstance } from 'fastify'; + +import { ModelConfig } from '../schema/config'; +import { registerIndexRoutes } from './index-route'; +import { registerSearchRoutes } from './search'; +import { registerEditRoutes } from './edit'; +import { registerDeleteRoutes } from './delete'; +import { registerAggregateRoutes } from './aggregate'; +import { registerPostRoutes } from './post'; +import { registerGetAllRoutes } from './get-all'; + +/** + * Register all config-driven model routes on the Fastify instance. + * + * Iterates through the provided models and registers routes for each + * API type based on field capabilities: + * - INDEX (primaryKey fields) + * - SEARCH (searchable fields) + * - EDIT (editable fields) + * - DELETE (deletable fields) + * - AGGREGATE (fields with supportedAggregation) + * - POST (table-level, create record) + * - GET_ALL (table-level, list all records) + */ +export function registerModelRoutes(app: FastifyInstance, models: ModelConfig[]): void { + registerIndexRoutes(app, models); + registerSearchRoutes(app, models); + registerEditRoutes(app, models); + registerDeleteRoutes(app, models); + registerAggregateRoutes(app, models); + registerPostRoutes(app, models); + registerGetAllRoutes(app, models); +} diff --git a/src/routes/post.ts b/src/routes/post.ts new file mode 100644 index 0000000..3c851dc --- /dev/null +++ b/src/routes/post.ts @@ -0,0 +1,42 @@ +import { FastifyInstance } from 'fastify'; + +import { ModelConfig } from '../schema/config'; +import { mapDataTypeToJsonSchema, helloWorldResponseSchema } from './schema-helpers'; + +/** + * Register POST routes for creating records (table-level). + * + * For each model that has fields, creates: + * POST /{model}/ + * + * Body: all fields as optional properties for creating a new record. + */ +export function registerPostRoutes(app: FastifyInstance, models: ModelConfig[]): void { + for (const model of models) { + if (model.fields.length === 0) continue; + + const bodyProperties: Record = {}; + for (const field of model.fields) { + bodyProperties[field.name] = { + ...mapDataTypeToJsonSchema(field.type), + description: `Value for ${field.name}`, + }; + } + + app.post( + `/${model.name}/`, + { + schema: { + description: `Create a new ${model.name} record`, + tags: [model.name], + body: { + type: 'object', + properties: bodyProperties, + }, + response: helloWorldResponseSchema, + }, + }, + async () => ({ message: 'hello world' }) + ); + } +} diff --git a/src/routes/schema-helpers.ts b/src/routes/schema-helpers.ts new file mode 100644 index 0000000..642a51a --- /dev/null +++ b/src/routes/schema-helpers.ts @@ -0,0 +1,126 @@ +import { DataType, ModelFieldConfig } from '../schema/config'; + +/** + * Map config DataType to JSON Schema type definition for Swagger. + */ +export function mapDataTypeToJsonSchema(type: DataType): { + type: string; + format?: string; +} { + switch (type) { + case 'integer': + return { type: 'integer' }; + case 'string': + return { type: 'string' }; + case 'boolean': + return { type: 'boolean' }; + case 'text': + return { type: 'string' }; + case 'datetime': + return { type: 'string', format: 'date-time' }; + default: + return { type: 'string' }; + } +} + +/** + * Standard pagination query parameter schema properties. + */ +export const paginationQueryProperties: Record = { + page: { + type: 'integer', + description: 'Page number (1-indexed)', + default: 1, + }, + limit: { + type: 'integer', + description: 'Number of records per page', + default: 20, + }, +}; + +/** + * Build sort query parameter schema properties for sortable fields. + */ +export function buildSortQueryProperties(sortableFields: string[]): Record { + if (sortableFields.length === 0) return {}; + return { + orderBy: { + type: 'string', + enum: sortableFields, + description: `Column to sort by. Allowed: ${sortableFields.join(', ')}`, + }, + orderDir: { + type: 'string', + enum: ['asc', 'desc'], + description: 'Sort direction', + default: 'asc', + }, + }; +} + +/** + * Build filter query parameter schema properties for a field + * based on its supportedOperations (lessThan, greaterThan, equal, oneOf, etc.). + */ +export function buildFilterQueryProperties(field: ModelFieldConfig): Record { + const ops = field.supportedOperations || []; + const jsonType = mapDataTypeToJsonSchema(field.type); + const properties: Record = {}; + + if (ops.includes('lessThan')) { + properties[`${field.name}_lt`] = { + ...jsonType, + description: `Filter where ${field.name} is less than this value`, + }; + } + + if (ops.includes('lessThanEqual')) { + properties[`${field.name}_lte`] = { + ...jsonType, + description: `Filter where ${field.name} is less than or equal to this value`, + }; + } + + if (ops.includes('greaterThan')) { + properties[`${field.name}_gt`] = { + ...jsonType, + description: `Filter where ${field.name} is greater than this value`, + }; + } + + if (ops.includes('greaterThanEqual')) { + properties[`${field.name}_gte`] = { + ...jsonType, + description: `Filter where ${field.name} is greater than or equal to this value`, + }; + } + + if (ops.includes('equal')) { + properties[`${field.name}_eq`] = { + ...jsonType, + description: `Filter where ${field.name} equals this value`, + }; + } + + if (ops.includes('oneOf')) { + properties[`${field.name}_in`] = { + type: 'string', + description: `Filter where ${field.name} is one of the provided comma-separated values`, + }; + } + + return properties; +} + +/** + * Standard hello world response schema (placeholder). + */ +export const helloWorldResponseSchema = { + 200: { + type: 'object', + properties: { + message: { type: 'string' }, + }, + }, +}; diff --git a/src/routes/search.ts b/src/routes/search.ts new file mode 100644 index 0000000..6b34a12 --- /dev/null +++ b/src/routes/search.ts @@ -0,0 +1,49 @@ +import { FastifyInstance } from 'fastify'; + +import { ModelConfig } from '../schema/config'; +import { paginationQueryProperties, helloWorldResponseSchema } from './schema-helpers'; + +/** + * Register SEARCH routes for searchable fields. + * + * For each model, for each field with 'searchable' in supportedOperations, creates: + * GET /{model}/search/{columnName} + * + * Query params: + * - {columnName}_search (required) — the search pattern + * - page, limit — pagination + */ +export function registerSearchRoutes(app: FastifyInstance, models: ModelConfig[]): void { + for (const model of models) { + const searchableFields = model.fields.filter((f) => + f.supportedOperations?.includes('searchable') + ); + + for (const field of searchableFields) { + const queryProperties: Record = { + [`${field.name}_search`]: { + type: 'string', + description: `Search pattern to match against ${field.name}`, + }, + ...paginationQueryProperties, + }; + + app.get( + `/${model.name}/search/${field.name}`, + { + schema: { + description: `Search ${model.name} by ${field.name}`, + tags: [model.name], + querystring: { + type: 'object', + properties: queryProperties, + required: [`${field.name}_search`], + }, + response: helloWorldResponseSchema, + }, + }, + async () => ({ message: 'hello world' }) + ); + } + } +} diff --git a/src/server.ts b/src/server.ts index 4d2938c..a799e28 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,6 +9,7 @@ import dbPlugin from './plugin/database'; import { createTables } from './database/table-creator'; import { createIndexes } from './database/index-creator'; import { createForeignKeys } from './database/fk-creator'; +import { registerModelRoutes } from './routes'; async function registerSwagger(swaggerConfig: SwaggerConfig, app: FastifyInstance) { if (swaggerConfig.enabled) { @@ -30,35 +31,6 @@ async function registerSwagger(swaggerConfig: SwaggerConfig, app: FastifyInstanc } } -async function registerRoutes(app: FastifyInstance) { - // Example route with schema - app.get( - '/hello', - { - schema: { - description: 'Hello route', - tags: ['Default'], - response: { - 200: { - type: 'object', - properties: { - message: { type: 'string' }, - }, - }, - }, - }, - }, - async () => { - try { - const users = await app.db.query('SELECT * FROM "users";'); - return { message: `Found ${users.length} users in the dynamic table.` }; - } catch (e: unknown) { - return { message: `Error querying users: ${(e as Error).message}` }; - } - } - ); -} - export async function startServer(config: AppConfig, port: number, mode: Mode) { const app: FastifyInstance = Fastify({ logger: @@ -91,8 +63,10 @@ export async function startServer(config: AppConfig, port: number, mode: Mode) { // register swagger await registerSwagger(config.swagger, app); - // register routes - await registerRoutes(app); + // register config-driven model routes + if (config.models && config.models.length > 0) { + registerModelRoutes(app, config.models); + } // Global error handler app.setErrorHandler((err: FastifyError, req: FastifyRequest, reply: FastifyReply) => {