diff --git a/core/builders/ApiBuilder.js b/core/builders/ApiBuilder.js new file mode 100644 index 0000000..c1fe596 --- /dev/null +++ b/core/builders/ApiBuilder.js @@ -0,0 +1,17 @@ +export default class ApiBuilder { + constructor(common){ + this.app=common.app; + this.middlewares=[...common.middlewares]; + } + method(m){ this.m=m.toLowerCase(); return this; } + url(u){ this.path=u.build(); return this; } + schema(s){ this.schemaBuilder=s; return this; } + sync(){ return this; } + build(dbClient){ + this.app[this.m]( + this.path, + ...this.middlewares, + (req,res)=>this.schemaBuilder.execute(req,res,dbClient) + ); + } +} \ No newline at end of file diff --git a/core/builders/CommonApiBuilder.js b/core/builders/CommonApiBuilder.js new file mode 100644 index 0000000..7dd94c9 --- /dev/null +++ b/core/builders/CommonApiBuilder.js @@ -0,0 +1,5 @@ +export default class CommonApiBuilder { + constructor(app){ this.app=app; this.middlewares=[]; } + use(mw){ this.middlewares.push(mw.middleware()); return this; } + build(){ return { app:this.app, middlewares:[...this.middlewares] }; } +} \ No newline at end of file diff --git a/core/builders/QueryBuilder.js b/core/builders/QueryBuilder.js new file mode 100644 index 0000000..5ffeea9 --- /dev/null +++ b/core/builders/QueryBuilder.js @@ -0,0 +1,9 @@ +export default class QueryBuilder { + constructor(){ this.query={}; } + fromJSON(q){ this.query=q; return this; } + select(cfg){ this.query={ op:'SELECT', ...cfg }; return this; } + insert(cfg){ this.query={ op:'INSERT', ...cfg }; return this; } + update(cfg){ this.query={ op:'UPDATE', ...cfg }; return this; } + delete(cfg){ this.query={ op:'DELETE', ...cfg }; return this; } + async execute(dbClient){ return dbClient.execute(this.query); } +} \ No newline at end of file diff --git a/core/builders/SchemaBuilder.js b/core/builders/SchemaBuilder.js new file mode 100644 index 0000000..7bad009 --- /dev/null +++ b/core/builders/SchemaBuilder.js @@ -0,0 +1,156 @@ +import PayloadValidator from '../validation/PayloadValidator.js'; +import QueryBuilder from './QueryBuilder.js'; + +export default class SchemaBuilder { + + constructor() { + this.errorSchemas = new Map(); + } + + requestSchema(s) { this.reqSchema = s; return this; } + responseSchema(s) { this.resSchema = s; return this; } + requestTranslator(f) { this.reqT = f; return this; } + responseTranslator(f) { this.resT = f; return this; } + preProcess(f) { this.pre = f; return this; } + postProcess(f) { this.post = f; return this; } + onError(c, s) { this.errorSchemas.set(c, s); return this; } + defaultError(s) { this.defErr = s; return this; } + pathSchema(schema) { this._pathSchema = schema; return this; } + querySchema(schema) { this._querySchema = schema; return this; } + headerSchema(schema) { this._headerSchema = schema; return this; } + + getErrorSchema(statusCode) { + return this.errorSchemas.has(statusCode) + ? this.errorSchemas.get(statusCode) + : this.defErr; + } + + log(req, level, message, meta = {}) { + const requestId = req?.requestId || 'unknown'; + console[level]( + `[SchemaBuilder] [${level.toUpperCase()}] [req:${requestId}] ${message}`, + Object.keys(meta).length ? meta : '' + ); + } + + async execute(req, res, dbClient) { + const start = Date.now(); + + try { + this.log(req, 'info', 'Execution started'); + + // ----------------------------- + // PATH PARAM VALIDATION + // ----------------------------- + if (this._pathSchema) { + this.log(req, 'debug', 'Validating path params'); + const vPath = PayloadValidator.validate(this._pathSchema, req.params); + if (!vPath.valid) { + this.log(req, 'warn', 'Path validation failed', vPath); + return res.status(400).json(vPath); + } + } + + // ----------------------------- + // QUERY PARAM VALIDATION + // ----------------------------- + if (this._querySchema) { + this.log(req, 'debug', 'Validating query params'); + const vQuery = PayloadValidator.validate(this._querySchema, req.query); + if (!vQuery.valid) { + this.log(req, 'warn', 'Query validation failed', vQuery); + return res.status(400).json(vQuery); + } + } + + // ----------------------------- + // HEADER VALIDATION + // ----------------------------- + if (this._headerSchema) { + this.log(req, 'debug', 'Validating headers'); + const vHeader = PayloadValidator.validate(this._headerSchema, req.headers); + if (!vHeader.valid) { + this.log(req, 'warn', 'Header validation failed', vHeader); + return res.status(400).json(vHeader); + } + } + + // ----------------------------- + // BODY VALIDATION + // ----------------------------- + if (this.reqSchema) { + this.log(req, 'debug', 'Validating request body'); + const vReq = PayloadValidator.validate(this.reqSchema, req.body); + if (!vReq.valid) { + this.log(req, 'warn', 'Body validation failed', vReq); + return res.status(400).json(vReq); + } + } + + // ----------------------------- + // PRE-PROCESS + // ----------------------------- + if (this.pre) { + this.log(req, 'debug', 'Running pre-process hook'); + const c = await this.pre(req.headers, req.params, req.query, req.body); + if (c !== 200) { + this.log(req, 'warn', 'Pre-process failed', { status: c }); + return res.status(c).json(this.getErrorSchema(c)); + } + } + + // ----------------------------- + // REQUEST TRANSLATION + // ----------------------------- + this.log(req, 'debug', 'Translating request to query'); + const q = this.reqT(req.headers, req.params, req.query, req.body); + + // ----------------------------- + // DB EXECUTION + // ----------------------------- + this.log(req, 'info', 'Executing DB query', { type: q?.type }); + const qb = new QueryBuilder().fromJSON(q); + const dbResp = await qb.execute(dbClient); + + // ----------------------------- + // RESPONSE TRANSLATION + // ----------------------------- + this.log(req, 'debug', 'Translating DB response'); + const resp = this.resT(dbResp, this.resSchema); + + // ----------------------------- + // RESPONSE VALIDATION + // ----------------------------- + this.log(req, 'debug', 'Validating response'); + const vResp = PayloadValidator.validate(this.resSchema, resp); + if (!vResp.valid) { + this.log(req, 'error', 'Response validation failed', vResp); + return res.status(500).json(vResp); + } + + // ----------------------------- + // POST-PROCESS + // ----------------------------- + if (this.post) { + this.log(req, 'debug', 'Running post-process hook'); + const c = await this.post(req, resp); + if (c !== 200) { + this.log(req, 'warn', 'Post-process failed', { status: c }); + return res.status(c).json(this.getErrorSchema(c)); + } + } + + const duration = Date.now() - start; + this.log(req, 'info', 'Execution completed', { durationMs: duration }); + + res.json(resp); + + } catch (e) { + this.log(req, 'error', 'Unhandled exception', { + message: e.message, + stack: e.stack + }); + res.status(500).json(this.defErr); + } + } +} diff --git a/core/builders/UrlBuilder.js b/core/builders/UrlBuilder.js new file mode 100644 index 0000000..d5999fd --- /dev/null +++ b/core/builders/UrlBuilder.js @@ -0,0 +1,33 @@ +export default class UrlBuilder { + constructor(baseUrl) { + this.baseUrl = baseUrl; + this.pathParams = {}; + this.queryParams = {}; + this.headerParams = {}; + } + + withPathParams(params = {}) { + this.pathParams = params; + return this; + } + + withQueryParams(params = {}) { + this.queryParams = params; + return this; + } + + withHeaderParams(params = {}) { + this.headerParams = params; + return this; + } + + build() { + let path = this.baseUrl; + + for (const key of Object.keys(this.pathParams)) { + path += `/:${key}`; + } + + return path; + } +} diff --git a/core/db/client.js b/core/db/client.js new file mode 100644 index 0000000..24ee9aa --- /dev/null +++ b/core/db/client.js @@ -0,0 +1,70 @@ +import knex from 'knex'; +import 'dotenv/config'; +import executeJsonQuery from './jsonQueryExecutor.js'; + +/** + * Knex configuration + * Uses environment variables with sane defaults + */ +const config = { + client: 'pg', + connection: { + host: process.env.PGHOST || '127.0.0.1', + port: Number(process.env.PGPORT || 5432), + user: process.env.PGUSER || 'postgres', + password: process.env.PGPASSWORD || 'postgres', + database: process.env.PGDATABASE || 'postgres', + }, + pool: { + min: 2, + max: 10, + }, +}; + +const knexInstance = knex(config); + +const dbClient = { + + async execute(query) { + if (!query || typeof query !== 'object') { + throw new Error('Invalid query object'); + } + + switch (query.type) { + + case 'json': { + return await executeJsonQuery(knexInstance, query); + } + + case 'raw-builder': { + if (typeof query.handler !== 'function') { + throw new Error('raw-builder requires a handler function'); + } + return await query.handler(knexInstance); + } + + case 'transaction': { + if (typeof query.handler !== 'function') { + throw new Error('transaction requires a handler function'); + } + + return await knexInstance.transaction(async (trx) => { + return await query.handler(trx); + }); + } + + default: + throw new Error(`Unsupported query type: ${query.type}`); + } + }, + + getKnex() { + return knexInstance; + }, + + async destroy() { + await knexInstance.destroy(); + } +}; + +export default dbClient; \ No newline at end of file diff --git a/core/db/jsonQueryExecutor.js b/core/db/jsonQueryExecutor.js new file mode 100644 index 0000000..f7cceb4 --- /dev/null +++ b/core/db/jsonQueryExecutor.js @@ -0,0 +1,107 @@ +/** + * Execute a JSON-based query using Knex + * + * Supported ops: + * SELECT | INSERT | UPDATE | DELETE + */ +export default function executeJsonQuery(knex, query) { + const { + op, + table, + columns, + values, + where, + orderBy, + limit, + offset + } = query; + + if (!op || !table) { + throw new Error('JSON query must include "op" and "table"'); + } + + let qb; + + switch (op) { + + // ---------------------------------- + // SELECT + // ---------------------------------- + case 'SELECT': { + qb = knex(table); + + if (Array.isArray(columns) && columns.length > 0) { + qb.select(columns); + } else { + qb.select('*'); + } + + if (where && typeof where === 'object') { + qb.where(where); + } + + if (Array.isArray(orderBy)) { + for (const { column, direction } of orderBy) { + qb.orderBy(column, direction || 'asc'); + } + } + + if (Number.isInteger(limit)) { + qb.limit(limit); + } + + if (Number.isInteger(offset)) { + qb.offset(offset); + } + + return qb; + } + + // ---------------------------------- + // INSERT + // ---------------------------------- + case 'INSERT': { + if (!values || typeof values !== 'object') { + throw new Error('INSERT requires "values"'); + } + + return knex(table) + .insert(values) + .returning(columns || '*'); + } + + // ---------------------------------- + // UPDATE + // ---------------------------------- + case 'UPDATE': { + if (!values || typeof values !== 'object') { + throw new Error('UPDATE requires "values"'); + } + + if (!where || typeof where !== 'object') { + throw new Error('UPDATE without WHERE is not allowed'); + } + + return knex(table) + .where(where) + .update(values) + .returning(columns || '*'); + } + + // ---------------------------------- + // DELETE + // ---------------------------------- + case 'DELETE': { + if (!where || typeof where !== 'object') { + throw new Error('DELETE without WHERE is not allowed'); + } + + return knex(table) + .where(where) + .del(); + } + + default: + throw new Error(`Unsupported JSON op: ${op}`); + } +} diff --git a/core/middleware/BaseMiddleware.js b/core/middleware/BaseMiddleware.js new file mode 100644 index 0000000..69aab8d --- /dev/null +++ b/core/middleware/BaseMiddleware.js @@ -0,0 +1,3 @@ +export default class BaseMiddleware { + middleware(){ throw new Error('middleware() not implemented'); } +} \ No newline at end of file diff --git a/core/middleware/CoarseAuthMiddleware.js b/core/middleware/CoarseAuthMiddleware.js new file mode 100644 index 0000000..408269f --- /dev/null +++ b/core/middleware/CoarseAuthMiddleware.js @@ -0,0 +1,11 @@ +import BaseMiddleware from './BaseMiddleware.js'; +export default class CoarseAuthMiddleware extends BaseMiddleware { + constructor(roles=[]){ super(); this.roles=roles; } + middleware(){ + return (req,res,next)=>{ + if(!this.roles.includes(req.user?.role)) + return res.status(403).json({ error:'FORBIDDEN' }); + next(); + }; + } +} \ No newline at end of file diff --git a/core/middleware/FineAuthMiddleware.js b/core/middleware/FineAuthMiddleware.js new file mode 100644 index 0000000..e053d1e --- /dev/null +++ b/core/middleware/FineAuthMiddleware.js @@ -0,0 +1,11 @@ +import BaseMiddleware from './BaseMiddleware.js'; +export default class FineAuthMiddleware extends BaseMiddleware { + constructor({ getResourceOwnerId }){ super(); this.getResourceOwnerId=getResourceOwnerId; } + middleware(){ + return (req,res,next)=>{ + if(req.user.role==='ADMIN') return next(); + if(req.user.userId===this.getResourceOwnerId(req)) return next(); + res.status(403).json({ error:'FORBIDDEN' }); + }; + } +} \ No newline at end of file diff --git a/core/middleware/JwtAuthMiddleware.js b/core/middleware/JwtAuthMiddleware.js new file mode 100644 index 0000000..de9bbcf --- /dev/null +++ b/core/middleware/JwtAuthMiddleware.js @@ -0,0 +1,22 @@ +import axios from 'axios'; +import BaseMiddleware from './BaseMiddleware.js'; +export default class JwtAuthMiddleware extends BaseMiddleware { + constructor(options={}){ + super(); + this.authServiceUrl=options.authServiceUrl||'http://auth-service:3000/auth/validate-token'; + } + middleware(){ + return async (req,res,next)=>{ + const h=req.headers.authorization; + if(!h) return res.status(401).json({ error:'UNAUTHORIZED' }); + try{ + const token=h.replace('Bearer ',''); + const r=await axios.post(this.authServiceUrl,{ token }); + req.user=r.data; + next(); + }catch{ + res.status(401).json({ error:'INVALID_TOKEN' }); + } + }; + } +} \ No newline at end of file diff --git a/core/middleware/RateLimiterMiddleware.js b/core/middleware/RateLimiterMiddleware.js new file mode 100644 index 0000000..9a53b44 --- /dev/null +++ b/core/middleware/RateLimiterMiddleware.js @@ -0,0 +1,5 @@ +import BaseMiddleware from './BaseMiddleware.js'; +export default class RateLimiterMiddleware extends BaseMiddleware { + constructor(createLimiterFn){ super(); this.createLimiterFn=createLimiterFn; } + middleware(){ return this.createLimiterFn(); } +} \ No newline at end of file diff --git a/core/types/PayloadTypes.js b/core/types/PayloadTypes.js new file mode 100644 index 0000000..ba4e750 --- /dev/null +++ b/core/types/PayloadTypes.js @@ -0,0 +1,7 @@ +export default { + STRING: 'string', + NUMBER: 'number', + ARRAY: 'Array', + JSON_OBJECT: 'JSONObject', + JSON_ARRAY: 'JSONArray' +}; \ No newline at end of file diff --git a/core/utils/SchemaFileLoader.js b/core/utils/SchemaFileLoader.js new file mode 100644 index 0000000..dcfbd42 --- /dev/null +++ b/core/utils/SchemaFileLoader.js @@ -0,0 +1,74 @@ +import fs from 'fs'; +import path from 'path'; + +export default class SchemaFileLoader { + /** + * Load a schema JSON file and resolve all nested json_dtype references + * + * @param {string} filePath - Absolute or relative path to schema file + * @param {Set} visited - Internal circular reference protection + * @returns {object} Fully resolved schema JSON + */ + static load(filePath, visited = new Set()) { + const absolutePath = path.resolve(filePath); + + if (visited.has(absolutePath)) { + throw new Error(`Circular schema reference detected: ${absolutePath}`); + } + + visited.add(absolutePath); + + if (!fs.existsSync(absolutePath)) { + throw new Error(`Schema file not found: ${absolutePath}`); + } + + const raw = fs.readFileSync(absolutePath, 'utf-8'); + const schema = JSON.parse(raw); + + const baseDir = path.dirname(absolutePath); + return this._resolveSchema(schema, baseDir, visited); + } + + /** + * Recursively resolve json_dtype references + */ + static _resolveSchema(schema, baseDir, visited) { + if (typeof schema !== 'object' || schema === null) return schema; + + for (const field in schema) { + const rule = schema[field]; + + if (!rule || typeof rule !== 'object') continue; + + // Resolve nested JSON object schema + if ( + rule.type === 'JSONObject' || + rule.type === 'JSONArray' + ) { + if (typeof rule.json_dtype === 'string') { + const nestedPath = path.join(baseDir, rule.json_dtype); + rule.json_dtype = this.load(nestedPath, visited); + } else if (typeof rule.json_dtype === 'object') { + rule.json_dtype = this._resolveSchema( + rule.json_dtype, + baseDir, + visited + ); + } + } + + // Resolve array of JSON objects + if ( + rule.type === 'Array' && + rule.array_dtype === 'JSONObject' + ) { + if (typeof rule.json_dtype === 'string') { + const nestedPath = path.join(baseDir, rule.json_dtype); + rule.json_dtype = this.load(nestedPath, visited); + } + } + } + + return schema; + } +} \ No newline at end of file diff --git a/core/validation/PayloadValidator.js b/core/validation/PayloadValidator.js new file mode 100644 index 0000000..63e2eba --- /dev/null +++ b/core/validation/PayloadValidator.js @@ -0,0 +1,39 @@ +import PayloadTypes from '../types/PayloadTypes.js'; +export default class PayloadValidator { + static validate(schema, payload, path = '') { + for (const field in schema) { + const rule = schema[field]; + const value = payload[field]; + const p = path ? `${path}.${field}` : field; + if (rule.is_mandatory_field && value === undefined) { + return { valid:false, error:`Missing field ${p}` }; + } + if (value === undefined) continue; + switch (rule.type) { + case PayloadTypes.STRING: + case PayloadTypes.NUMBER: + if (typeof value !== rule.type) return { valid:false, error:`Invalid type ${p}` }; + break; + case PayloadTypes.ARRAY: + case PayloadTypes.JSON_ARRAY: + if (!Array.isArray(value)) return { valid:false, error:`Invalid array ${p}` }; + if (rule.array_dtype) { + for (const v of value) { + if (typeof v !== rule.array_dtype) + return { valid:false, error:`Invalid array dtype ${p}` }; + } + } + break; + case PayloadTypes.JSON_OBJECT: + if (typeof value !== 'object' || Array.isArray(value)) + return { valid:false, error:`Invalid object ${p}` }; + if (rule.json_dtype) { + const nested = this.validate(rule.json_dtype, value, p); + if (!nested.valid) return nested; + } + break; + } + } + return { valid:true }; + } +} \ No newline at end of file diff --git a/logic/listings/listingRequestTranslator.js b/logic/listings/listingRequestTranslator.js new file mode 100644 index 0000000..bf98b37 --- /dev/null +++ b/logic/listings/listingRequestTranslator.js @@ -0,0 +1,89 @@ +export default function listingRequestTranslator( + headerParams, + pathParams, + queryParams, + body +) { + + const { + id + } = pathParams || {}; + + const { + limit = 20, + offset = 0, + + species_id, + breed_id, + sex, + min_price, + max_price, + status + } = queryParams || {}; + + // ----------------------------- + // BASE WHERE CLAUSE + // ----------------------------- + const where = { + deleted: false + }; + + // ----------------------------- + // PATH FILTER (single listing) + // ----------------------------- + if (id) { + where.id = id; + } + + // ----------------------------- + // FILTERS (denormalized columns) + // ----------------------------- + if (species_id) { + where.filter_species_id = species_id; + } + + if (breed_id) { + where.filter_breed_id = breed_id; + } + + if (sex) { + where.filter_sex = sex; + } + + if (status) { + where.status = status; + } + + // ----------------------------- + // BUILD QUERY JSON + // ----------------------------- + const query = { + type: 'json', + op: 'SELECT', + table: 'listings', + + where, + + orderBy: [ + { column: 'created_at', direction: 'desc' } + ], + + limit: Math.min(Number(limit), 100), + offset: Number(offset) + }; + + // ----------------------------- + // PRICE RANGE (handled separately) + // ----------------------------- + // JSON executor extension point: + // price range is encoded as a special where clause + if (min_price !== undefined || max_price !== undefined) { + query.whereRange = { + column: 'price', + min: min_price !== undefined ? Number(min_price) : undefined, + max: max_price !== undefined ? Number(max_price) : undefined + }; + } + + return query; +} diff --git a/logic/listings/listingResponseTranslator.js b/logic/listings/listingResponseTranslator.js new file mode 100644 index 0000000..3413968 --- /dev/null +++ b/logic/listings/listingResponseTranslator.js @@ -0,0 +1,53 @@ +export default function listingResponseTranslator(dbResp, respSchema) { + + // ----------------------------- + // Normalize DB response + // ----------------------------- + const rows = Array.isArray(dbResp) + ? dbResp + : dbResp + ? [dbResp] + : []; + + // ----------------------------- + // Pick only fields defined in response schema + // ----------------------------- + const allowedFields = respSchema + ? Object.keys(respSchema) + : null; + + const normalizeRow = (row) => { + if (!row || typeof row !== 'object') return null; + + // If schema exists, filter fields + if (allowedFields) { + const filtered = {}; + for (const key of allowedFields) { + if (row[key] !== undefined) { + filtered[key] = row[key]; + } + } + return filtered; + } + + // Fallback (should not happen normally) + return row; + }; + + // ----------------------------- + // Map rows + // ----------------------------- + const result = rows + .map(normalizeRow) + .filter(Boolean); + + // ----------------------------- + // Return shape + // ----------------------------- + // If schema represents a single object, return object + if (!Array.isArray(respSchema)) { + return result.length === 1 ? result[0] : result; + } + + return result; +} diff --git a/models/listings/listing_request_path_param.json b/models/listings/listing_request_path_param.json new file mode 100644 index 0000000..92a6282 --- /dev/null +++ b/models/listings/listing_request_path_param.json @@ -0,0 +1,6 @@ +{ + "listingId": { + "is_mandatory_field": true, + "type": "number" + } +} \ No newline at end of file diff --git a/models/listings/listings_response.json b/models/listings/listings_response.json new file mode 100644 index 0000000..68902a1 --- /dev/null +++ b/models/listings/listings_response.json @@ -0,0 +1,46 @@ +{ + "seller_id": { + "is_mandatory_field": true, + "type": "string" + }, + + "animal_id": { + "is_mandatory_field": true, + "type": "string" + }, + + "title": { + "is_mandatory_field": true, + "type": "string" + }, + + "price": { + "is_mandatory_field": false, + "type": "number" + }, + + "currency": { + "is_mandatory_field": false, + "type": "string" + }, + + "is_negotiable": { + "is_mandatory_field": false, + "type": "boolean" + }, + + "listing_type": { + "is_mandatory_field": false, + "type": "string" + }, + + "status": { + "is_mandatory_field": false, + "type": "string" + }, + + "thumbnail_url": { + "is_mandatory_field": false, + "type": "string" + } +} \ No newline at end of file diff --git a/models/user/user_contacts.json b/models/user/user_contacts.json new file mode 100644 index 0000000..6857a9b --- /dev/null +++ b/models/user/user_contacts.json @@ -0,0 +1,10 @@ +{ + "phone": { + "is_mandatory_field": true, + "type": "string" + }, + "email": { + "is_mandatory_field": false, + "type": "string" + } +} diff --git a/models/user/user_details_request.json b/models/user/user_details_request.json new file mode 100644 index 0000000..947b561 --- /dev/null +++ b/models/user/user_details_request.json @@ -0,0 +1,15 @@ +{ + "id": { + "is_mandatory_field": true, + "type": "number" + }, + "name": { + "is_mandatory_field": true, + "type": "string" + }, + "contacts": { + "is_mandatory_field": false, + "type": "JSONArray", + "json_dtype": "user_contacts.json" + } +} diff --git a/models/user/user_details_response.json b/models/user/user_details_response.json new file mode 100644 index 0000000..947b561 --- /dev/null +++ b/models/user/user_details_response.json @@ -0,0 +1,15 @@ +{ + "id": { + "is_mandatory_field": true, + "type": "number" + }, + "name": { + "is_mandatory_field": true, + "type": "string" + }, + "contacts": { + "is_mandatory_field": false, + "type": "JSONArray", + "json_dtype": "user_contacts.json" + } +} diff --git a/package-lock.json b/package-lock.json index 4930a49..a5363b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -346,6 +346,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -2203,6 +2204,7 @@ "version": "8.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", diff --git a/routes/listings.route.js b/routes/listings.route.js new file mode 100644 index 0000000..57d6ea2 --- /dev/null +++ b/routes/listings.route.js @@ -0,0 +1,70 @@ +import ApiBuilder from '../core/builders/ApiBuilder.js'; +import UrlBuilder from '../core/builders/UrlBuilder.js'; +import SchemaBuilder from '../core/builders/SchemaBuilder.js'; +import SchemaFileLoader from '../core/utils/SchemaFileLoader.js'; +import listingRequestTranslator from '../logic/listings/listingRequestTranslator.js'; +import listingResponseTranslator from '../logic/listings/listingResponseTranslator.js'; + +export default function registerListingsApi(baseUrl, common, dbClient) { + + // GET /listings (feed) + new ApiBuilder(common) + .method('get') + .url(new UrlBuilder(baseUrl)) + .schema( + new SchemaBuilder() + .responseSchema( + SchemaFileLoader.load('./models/listings/listings_response.json') + ) + .requestTranslator(listingRequestTranslator) + .responseTranslator(listingResponseTranslator) + .defaultError({ error: 'INTERNAL_ERROR' }) + ) + .sync() + .build(dbClient); + + // GET /listings/:id + new ApiBuilder(common) + .method('get') + .url(new UrlBuilder(baseUrl).withPathParams(SchemaFileLoader.load('./models/listings/listing_request_path_param.json'))) + .schema( + new SchemaBuilder() + .responseSchema( + SchemaFileLoader.load('./models/listings/listings_response.json') + ) + .requestTranslator(listingRequestTranslator) + .responseTranslator(listingResponseTranslator) + .defaultError({ error: 'NOT_FOUND' }) + ) + .sync() + .build(dbClient); + + // POST /listings (create) + new ApiBuilder(common) + .method('post') + .url(new UrlBuilder(baseUrl)) + .schema( + new SchemaBuilder() + .requestSchema( + SchemaFileLoader.load('./models/listings/listings_response.json') + ) + .responseSchema( + SchemaFileLoader.load('./models/listings/listings_response.json') + ) + .requestTranslator(req => ({ + type: 'transaction', + handler: async (trx) => { + const [listing] = await trx('listings') + .insert(req.body) + .returning('*'); + + return listing; + } + })) + .responseTranslator(result => result) + .defaultError({ error: 'CREATE_FAILED' }) + ) + .sync() + .build(dbClient); + +}; diff --git a/server.js b/server.js index b681ccc..8bf454e 100644 --- a/server.js +++ b/server.js @@ -4,7 +4,7 @@ import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import http from "http"; import dotenv from "dotenv"; -import listingRoutes from "./routes/listingRoutes.js"; +// import listingRoutes from "./routes/listingRoutes.js"; import locationRoutes from "./routes/locationRoutes.js"; import chatRoutes from "./routes/chatRoutes.js"; import userRoutes from "./routes/userRoutes.js"; @@ -15,6 +15,13 @@ import { startExpirationJob } from "./jobs/expirationJob.js"; import requestContext from "./middleware/requestContext.js"; import { auditLoggerMiddleware } from "./services/auditLogger.js"; +import registerListingsApi from "./routes/listings.route.js"; +import CommonApiBuilder from "./core/builders/CommonApiBuilder.js"; +// import JwtAuthMiddleware from "./core/middleware/JwtAuthMiddleware.js"; +// import RateLimiterMiddleware from "./core/middleware/RateLimiterMiddleware.js"; + +import dbClient from "./core/db/client.js"; + // Load environment variables dotenv.config(); @@ -44,8 +51,17 @@ app.use(express.static(join(__dirname, 'public'))); const PORT = process.env.PORT || 3200; + +const common = new CommonApiBuilder(app) + // .use(new JwtAuthMiddleware()) + // .use(new RateLimiterMiddleware(rateLimiterRead)) + // .use(new CoarseAuthMiddleware(['USER', 'ADMIN'])) + .build(); + +// register ALL listing APIs +registerListingsApi("/listings", common, dbClient); + // Add routes here -app.use("/listings", listingRoutes); app.use("/locations", locationRoutes); app.use("/chat", chatRoutes); app.use("/users", userRoutes);