Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/backend-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Backend Tests

on:
pull_request:
paths:
- 'backend/**'
- 'config/distros.json'
- '.github/workflows/backend-tests.yml'
push:
branches:
- master
paths:
- 'backend/**'
- 'config/distros.json'
- '.github/workflows/backend-tests.yml'

jobs:
test:
runs-on: ubuntu-latest

defaults:
run:
working-directory: backend

steps:
- name: Checkout repository
uses: actions/checkout@v5

- name: Set up Node.js
uses: actions/setup-node@v5
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: backend/package-lock.json

- name: Install dependencies
run: npm ci

- name: Run backend tests
run: npm test
294 changes: 294 additions & 0 deletions backend/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,294 @@
import express from 'express';
import cors from 'cors';
import swaggerJsdoc from 'swagger-jsdoc';
import swaggerUi from 'swagger-ui-express';
import { SUPPORTED_DISTROS, MAX_RECORDS_TO_SEND } from './config.js';
import { buildDistroBitMap, getTables, stringifyBigInts } from './utils.js';

const createCorsMiddleware = () => {
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',')
: ['http://localhost:3000'];

return cors({
origin: (origin, callback) => {
if (!origin) return callback(null, true);
if (allowedOrigins.indexOf(origin) === -1) {
const msg = 'The CORS policy for this site does not allow access from the specified Origin.';
return callback(new Error(msg), false);
}
return callback(null, true);
}
});
};

const createSwaggerMiddleware = () => {
const swaggerOptions = {
definition: {
openapi: '3.0.0',
info: {
title: 'Software Discovery Tool API',
version: '1.0.0',
description: 'REST API for the Open Mainframe Software Discovery Tool. Replaces the Flask backend.',
},
servers: [{ url: `http://localhost:${process.env.PORT || 5000}` }],
},
apis: ['./app.js'],
};
const swaggerSpec = swaggerJsdoc(swaggerOptions);
return [swaggerUi.serve, swaggerUi.setup(swaggerSpec)];
};

export const createApp = (pool) => {
const app = express();
const distroBitMap = buildDistroBitMap(SUPPORTED_DISTROS);

app.use(createCorsMiddleware());
app.use(express.json());
app.use('/api-docs', ...createSwaggerMiddleware());

const setNoCacheHeaders = (res) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
};

const getSupportedDistrosHandler = (req, res) => {
setNoCacheHeaders(res);
res.json(stringifyBigInts(distroBitMap));
};

/**
* @swagger
* /getSupportedDistros:
* get:
* summary: Returns a mapping of supported distributions and their versions with bitmask flags.
* responses:
* 200:
* description: A JSON object containing the distribution bit map.
* content:
* application/json:
* schema:
* type: object
*/
app.get('/getSupportedDistros', getSupportedDistrosHandler);

/**
* @swagger
* /sdt/getSupportedDistros:
* get:
* summary: Returns a mapping of supported distributions and their versions with bitmask flags (legacy path).
* responses:
* 200:
* description: A JSON object containing the distribution bit map.
* content:
* application/json:
* schema:
* type: object
*/
app.get('/sdt/getSupportedDistros', getSupportedDistrosHandler);

/**
* @swagger
* /searchPackages:
* get:
* summary: Search for software packages across multiple distributions.
* parameters:
* - in: query
* name: search_term
* schema:
* type: string
* required: true
* description: The term to search for.
* - in: query
* name: exact_match
* schema:
* type: boolean
* description: Whether to perform an exact match on the package name.
* - in: query
* name: search_bit_flag
* schema:
* type: string
* description: A bitmask representing the selected distributions and versions.
* - in: query
* name: page_number
* schema:
* type: integer
* description: The page number for pagination.
* - in: query
* name: search_description
* schema:
* type: boolean
* description: Whether to search within package descriptions.
* - in: query
* name: limit
* schema:
* type: integer
* description: Number of records to return per page.
* responses:
* 200:
* description: A paginated list of matching packages.
* content:
* application/json:
* schema:
* type: object
* properties:
* total_packages:
* type: integer
* current_page:
* type: integer
* last_page:
* type: integer
* more_available:
* type: boolean
* packages:
* type: array
* items:
* type: object
* 500:
* description: Server error.
*/

/**
* @swagger
* /sdt/searchPackages:
* get:
* summary: Search for software packages across multiple distributions (legacy path).
* parameters:
* - in: query
* name: search_term
* schema:
* type: string
* required: true
* description: The term to search for.
* - in: query
* name: exact_match
* schema:
* type: boolean
* description: Whether to perform an exact match on the package name.
* - in: query
* name: search_bit_flag
* schema:
* type: string
* description: A bitmask representing the selected distributions and versions.
* - in: query
* name: page_number
* schema:
* type: integer
* description: The page number for pagination.
* - in: query
* name: search_description
* schema:
* type: boolean
* description: Whether to search within package descriptions.
* - in: query
* name: limit
* schema:
* type: integer
* description: Number of records to return per page.
* responses:
* 200:
* description: A paginated list of matching packages.
* content:
* application/json:
* schema:
* type: object
* 500:
* description: Server error.
*/
app.get(['/searchPackages', '/sdt/searchPackages'], async (req, res) => {
const searchTerm = (req.query.search_term || '').trim();
const exactMatch = req.query.exact_match === 'true';
const searchDescription = req.query.search_description === 'true';
const searchBitFlag = req.query.search_bit_flag || '0';
const pageNumber = parseInt(req.query.page_number || '0');
const limit = Math.min(parseInt(req.query.limit || MAX_RECORDS_TO_SEND), 500);

if (isNaN(pageNumber) || pageNumber < 0) {
return res.status(400).json({ error: 'Invalid page_number' });
}

if (isNaN(limit) || limit <= 0) {
return res.status(400).json({ error: 'Invalid limit' });
}

try {
BigInt(searchBitFlag);
} catch (e) {
return res.status(400).json({ error: 'Invalid search_bit_flag' });
}

if (!searchTerm) {
return res.json({
total_packages: 0,
current_page: 0,
last_page: 0,
more_available: false,
packages: []
});
}

try {
const tables = getTables(searchBitFlag, distroBitMap, SUPPORTED_DISTROS);
if (tables.length === 0) {
return res.json({
total_packages: 0,
current_page: pageNumber,
last_page: 0,
more_available: false,
packages: []
});
}

let sqlSearchTerm = searchTerm;
if (!exactMatch) {
if (sqlSearchTerm.includes('*')) {
sqlSearchTerm = sqlSearchTerm.replace(/\*/g, '%');
} else {
sqlSearchTerm = `%${sqlSearchTerm}%`;
}
}

const subQuery = exactMatch
? 'SELECT packageName, description, version, osName FROM ?? WHERE packageName = ?'
: (searchDescription
? 'SELECT packageName, description, version, osName FROM ?? WHERE (packageName LIKE ? OR description LIKE ?)'
: 'SELECT packageName, description, version, osName FROM ?? WHERE packageName LIKE ?');

const unionQuery = tables.map(() => `(${subQuery})`).join(' UNION ALL ');
const countQuery = `SELECT COUNT(*) as total FROM (${unionQuery}) AS combined`;

const queryParams = [];
tables.forEach(table => {
queryParams.push(table);
queryParams.push(sqlSearchTerm);
if (!exactMatch && searchDescription) {
queryParams.push(sqlSearchTerm);
}
});

const [countResult] = await pool.query(countQuery, queryParams);
const totalLength = countResult[0].total;

const dataQuery = `${unionQuery} LIMIT ? OFFSET ?`;
const dataParams = [...queryParams, limit, pageNumber * limit];

const [results] = await pool.query(dataQuery, dataParams);
const lastPage = Math.ceil(totalLength / limit);

setNoCacheHeaders(res);
res.json({
total_packages: totalLength,
current_page: pageNumber,
last_page: lastPage,
more_available: (pageNumber + 1) * limit < totalLength,
packages: results
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Internal server error' });
}
});

return app;
};
Loading
Loading