Generic API builder
This commit is contained in:
parent
8e6f6d32d4
commit
0353d4c642
|
|
@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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] }; }
|
||||||
|
}
|
||||||
|
|
@ -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); }
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export default class BaseMiddleware {
|
||||||
|
middleware(){ throw new Error('middleware() not implemented'); }
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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' });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(); }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export default {
|
||||||
|
STRING: 'string',
|
||||||
|
NUMBER: 'number',
|
||||||
|
ARRAY: 'Array',
|
||||||
|
JSON_OBJECT: 'JSONObject',
|
||||||
|
JSON_ARRAY: 'JSONArray'
|
||||||
|
};
|
||||||
|
|
@ -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<string>} 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"listingId": {
|
||||||
|
"is_mandatory_field": true,
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"phone": {
|
||||||
|
"is_mandatory_field": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"is_mandatory_field": false,
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -346,6 +346,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cluster-key-slot": "1.1.2",
|
"cluster-key-slot": "1.1.2",
|
||||||
"generic-pool": "3.9.0",
|
"generic-pool": "3.9.0",
|
||||||
|
|
@ -2203,6 +2204,7 @@
|
||||||
"version": "8.16.3",
|
"version": "8.16.3",
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||||
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.9.1",
|
"pg-connection-string": "^2.9.1",
|
||||||
"pg-pool": "^3.10.1",
|
"pg-pool": "^3.10.1",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
};
|
||||||
20
server.js
20
server.js
|
|
@ -4,7 +4,7 @@ import { fileURLToPath } from 'url';
|
||||||
import { dirname, join } from 'path';
|
import { dirname, join } from 'path';
|
||||||
import http from "http";
|
import http from "http";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import listingRoutes from "./routes/listingRoutes.js";
|
// import listingRoutes from "./routes/listingRoutes.js";
|
||||||
import locationRoutes from "./routes/locationRoutes.js";
|
import locationRoutes from "./routes/locationRoutes.js";
|
||||||
import chatRoutes from "./routes/chatRoutes.js";
|
import chatRoutes from "./routes/chatRoutes.js";
|
||||||
import userRoutes from "./routes/userRoutes.js";
|
import userRoutes from "./routes/userRoutes.js";
|
||||||
|
|
@ -15,6 +15,13 @@ import { startExpirationJob } from "./jobs/expirationJob.js";
|
||||||
import requestContext from "./middleware/requestContext.js";
|
import requestContext from "./middleware/requestContext.js";
|
||||||
import { auditLoggerMiddleware } from "./services/auditLogger.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
|
// Load environment variables
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
|
@ -44,8 +51,17 @@ app.use(express.static(join(__dirname, 'public')));
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3200;
|
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
|
// Add routes here
|
||||||
app.use("/listings", listingRoutes);
|
|
||||||
app.use("/locations", locationRoutes);
|
app.use("/locations", locationRoutes);
|
||||||
app.use("/chat", chatRoutes);
|
app.use("/chat", chatRoutes);
|
||||||
app.use("/users", userRoutes);
|
app.use("/users", userRoutes);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue