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",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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 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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue