Refactored backend.

- Add versioning to API endpoints.
- Refactored $db and $repos as global variables.
- Extracted routes into separate components with deferred registration.
- Support for a different db URL for location data than the one used by
  the application.
- Added sqlite and passport dependencies (passport will soon be used to
  handle authentication).
This commit is contained in:
Fabio Manganiello 2025-03-01 11:45:13 +01:00
parent f349b2c5be
commit 533ebe960f
Signed by: blacklight
GPG key ID: D90FBA7F76362774
26 changed files with 1853 additions and 146 deletions

View file

@ -8,12 +8,19 @@ BACKEND_ADDRESS=127.0.0.1
# Listen port for the backend (default: 3000)
BACKEND_PORT=3000
# Database URL (required)
# Application database URL (required)
DB_URL=postgres://user:password@host:port/dbname
# If the location data is stored on another db than the one used by the backend,
# you can specify a different database URL here.
# DB_LOCATION_URL=postgres://user:password@host:port/dbname
# Database dialect (default: inferred from the URL)
# DB_DIALECT=postgres
# Location database dialect (default: inferred from the URL)
# DB_LOCATION_DIALECT=postgres
# Name of the table that contains the location points (required)
DB_LOCATION_TABLE=gpsdata
@ -55,4 +62,4 @@ DB_LOCATION__POSTAL_CODE=postal_code
###
VITE_API_BASE_URL=http://localhost:${BACKEND_PORT}
VITE_API_PATH=/api
VITE_API_PATH=/api/v1

View file

@ -11,7 +11,7 @@ export default defineConfig((env) => {
const serverURL = new URL(
envars.VITE_API_SERVER_URL ?? 'http://localhost:3000'
);
const serverAPIPath = envars.VITE_API_PATH ?? '/api';
const serverAPIPath = envars.VITE_API_PATH ?? '/api/v1';
return {
envDir: envDir,

1481
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -27,8 +27,10 @@
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"passport": "^0.7.0",
"pg": "^8.13.3",
"sequelize": "^6.37.5"
"sequelize": "^6.37.5",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"@types/cors": "^2.8.17",

View file

@ -1,38 +1,58 @@
import { Sequelize, DataTypes, Dialect } from 'sequelize';
import { Sequelize, Dialect } from 'sequelize';
export class Db {
import GPSData from './types/GPSData';
class Db {
private readonly url: string;
private readonly locationUrl: string;
private readonly locationTable: string;
public readonly locationTableColumns: object;
public readonly locationTableColumns: Record<string, string>;
private readonly dialect: Dialect;
private readonly sequelize: Sequelize;
private readonly locationDialect: Dialect;
private readonly appDb: Sequelize;
private readonly locationDb: Sequelize;
private static readonly envColumnPrefix = 'DB_LOCATION__';
private constructor(
opts: {
url: string,
locationUrl: string,
locationTable: string,
locationTableColumns: object,
locationTableColumns: Record<string, string>,
dialect: Dialect,
locationDialect: Dialect | null,
}
) {
this.url = opts.url;
this.locationUrl = opts.locationUrl;
this.locationTable = opts.locationTable;
this.locationTableColumns = opts.locationTableColumns
this.dialect = opts.dialect as Dialect;
this.locationDialect = (opts.locationDialect || this.dialect) as Dialect;
this.sequelize = new Sequelize(this.url, {
this.appDb = new Sequelize(this.url, {
dialect: this.dialect,
logging: process.env.DEBUG === 'true' ? console.log : false
});
if (this.locationUrl === this.url) {
this.locationDb = this.appDb;
} else {
this.locationDb = new Sequelize(this.locationUrl, {
dialect: this.locationDialect,
logging: process.env.DEBUG === 'true' ? console.log : false
});
}
}
public static fromEnv(): Db {
const opts: any = {}
opts.url = process.env.DB_URL;
opts.locationUrl = process.env.DB_LOCATION_URL || opts.url;
opts.locationTable = process.env.DB_LOCATION_TABLE;
opts.dialect = process.env.DB_DIALECT || opts.url.split(':')[0];
opts.locationDialect = process.env.DB_LOCATION_DIALECT || opts.locationUrl.split(':')[0];
if (!opts.url?.length) {
console.error('No DB_URL provided');
@ -77,82 +97,16 @@ export class Db {
return `${Db.envColumnPrefix}${name.toUpperCase()}`;
}
public GpsData() {
const typeDef: any = {};
/**
* Tables
*/
// @ts-expect-error
typeDef[this.locationTableColumns['id']] = {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
};
// @ts-expect-error
typeDef[this.locationTableColumns['latitude']] = {
type: DataTypes.FLOAT,
allowNull: false
};
// @ts-expect-error
typeDef[this.locationTableColumns['longitude']] = {
type: DataTypes.FLOAT,
allowNull: false
};
// @ts-expect-error
const altitudeCol: string = this.locationTableColumns['altitude'];
if (altitudeCol?.length) {
typeDef[altitudeCol] = {
type: DataTypes.FLOAT,
allowNull: true
};
}
// @ts-expect-error
const addressCol: string = this.locationTableColumns['address'];
if (addressCol?.length) {
typeDef[addressCol] = {
type: DataTypes.STRING,
allowNull: true
};
}
// @ts-expect-error
const localityCol: string = this.locationTableColumns['locality'];
if (localityCol?.length) {
typeDef[localityCol] = {
type: DataTypes.STRING,
allowNull: true
};
}
// @ts-expect-error
const countryCol: string = this.locationTableColumns['country'];
if (countryCol?.length) {
typeDef[countryCol] = {
type: DataTypes.STRING,
allowNull: true
};
}
// @ts-expect-error
const postalCodeCol: string = this.locationTableColumns['postal_code'];
if (postalCodeCol?.length) {
typeDef[postalCodeCol] = {
type: DataTypes.STRING,
allowNull: true
};
}
// @ts-expect-error
typeDef[this.locationTableColumns['timestamp']] = {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW
};
return this.sequelize.define('GpsData', typeDef, {
public GPSData() {
return this.locationDb.define('GPSData', GPSData(this.locationTableColumns), {
tableName: this.locationTable,
timestamps: false
});
}
}
export default Db;

3
src/db/index.ts Normal file
View file

@ -0,0 +1,3 @@
import Db from "./Db";
export { Db };

70
src/db/types/GPSData.ts Normal file
View file

@ -0,0 +1,70 @@
import { DataTypes } from 'sequelize';
function GPSData(locationTableColumns: Record<string, string>): Record<string, any> {
const typeDef: Record<string, any> = {};
typeDef[locationTableColumns['id']] = {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
};
typeDef[locationTableColumns['latitude']] = {
type: DataTypes.FLOAT,
allowNull: false
};
typeDef[locationTableColumns['longitude']] = {
type: DataTypes.FLOAT,
allowNull: false
};
const altitudeCol: string = locationTableColumns['altitude'];
if (altitudeCol?.length) {
typeDef[altitudeCol] = {
type: DataTypes.FLOAT,
allowNull: true
};
}
const addressCol: string = locationTableColumns['address'];
if (addressCol?.length) {
typeDef[addressCol] = {
type: DataTypes.STRING,
allowNull: true
};
}
const localityCol: string = locationTableColumns['locality'];
if (localityCol?.length) {
typeDef[localityCol] = {
type: DataTypes.STRING,
allowNull: true
};
}
const countryCol: string = locationTableColumns['country'];
if (countryCol?.length) {
typeDef[countryCol] = {
type: DataTypes.STRING,
allowNull: true
};
}
const postalCodeCol: string = locationTableColumns['postal_code'];
if (postalCodeCol?.length) {
typeDef[postalCodeCol] = {
type: DataTypes.STRING,
allowNull: true
};
}
typeDef[locationTableColumns['timestamp']] = {
type: DataTypes.DATE,
defaultValue: DataTypes.NOW
};
return typeDef;
}
export default GPSData;

16
src/globals.ts Normal file
View file

@ -0,0 +1,16 @@
import dotenv from 'dotenv';
import { Db } from './db';
import Repositories from './repos';
dotenv.config();
declare global {
var $db: Db;
var $repos: Repositories;
}
export function useGlobals() {
globalThis.$db = Db.fromEnv();
globalThis.$repos = new Repositories(globalThis.$db);
}

View file

@ -1,49 +1,20 @@
import cors from 'cors';
import express from 'express';
import dotenv from 'dotenv';
import { Db } from './db/Db';
import { LocationRequest } from './models/LocationRequest';
import { LocationRepository } from './repo/LocationRepository';
import { logRequest } from './helpers/logging';
import { useGlobals } from './globals';
useGlobals();
dotenv.config();
import Routes from './routes';
const app = express();
const db = Db.fromEnv();
const locationRepo = new LocationRepository(db);
const address = process.env.BACKEND_ADDRESS || '127.0.0.1';
const port = process.env.BACKEND_PORT || 3000;
const routes = new Routes();
// Middleware
app.use(cors());
app.use(express.static('frontend/dist'));
routes.register(app)
// API route
app.get('/api/gpsdata', async (req, res) => {
logRequest(req);
let query: any = {};
try {
query = new LocationRequest(req.query);
} catch (error) {
const e = `Error parsing query: ${error}`;
console.warn(e);
res.status(400).send(e);
return;
}
try {
const gpsData = await locationRepo.getHistory(query);
res.json(gpsData);
} catch (error) {
const e = `Error fetching data: ${error}`;
console.error(e);
res.status(500).send(e);
}
});
// @ts-ignore
app.listen(port, address, () => {
app.listen(new Number(port).valueOf(), address, () => {
console.log(`Server is running on port ${address}:${port}`);
});

View file

@ -22,4 +22,4 @@ class GPSPoint {
}
}
export { GPSPoint };
export default GPSPoint;

View file

@ -1,3 +0,0 @@
type Nullable<T> = T | null;
export { Nullable };

5
src/models/index.ts Normal file
View file

@ -0,0 +1,5 @@
import GPSPoint from "./GPSPoint";
export {
GPSPoint,
};

View file

@ -1,8 +1,8 @@
import { Db } from '../db/Db';
import { GPSPoint } from '../models/GPSPoint';
import { LocationRequest } from 'src/models/LocationRequest';
import { Db } from '~/db';
import { GPSPoint } from '../models';
import { LocationRequest } from '../requests';
export class LocationRepository {
class LocationRepository {
private db: Db;
constructor(db: Db) {
@ -13,7 +13,7 @@ export class LocationRepository {
let apiResponse: any[] = [];
try {
apiResponse = await this.db.GpsData().findAll(query.toMap(this.db));
apiResponse = await this.db.GPSData().findAll(query.toMap(this.db));
} catch (error) {
throw new Error(`Error fetching data: ${error}`);
}
@ -40,3 +40,5 @@ export class LocationRepository {
}
}
}
export default LocationRepository;

12
src/repos/index.ts Normal file
View file

@ -0,0 +1,12 @@
import { Db } from 'src/db';
import LocationRepository from './LocationRepository';
class Repositories {
public location: LocationRepository;
constructor(db: Db) {
this.location = new LocationRepository(db);
}
}
export default Repositories;

View file

@ -1,17 +1,18 @@
import { Nullable } from './Types';
import { Db } from '../db/Db';
import { Op } from 'sequelize';
import { Optional } from 'src/types';
import { Db } from 'src/db';
class LocationRequest {
limit: Nullable<number> = 250;
offset: Nullable<number> = null;
startDate: Nullable<Date> = null;
endDate: Nullable<Date> = null;
minId: Nullable<number> = null;
maxId: Nullable<number> = null;
country: Nullable<string> = null;
locality: Nullable<string> = null;
postalCode: Nullable<string> = null;
limit: Optional<number> = 250;
offset: Optional<number> = null;
startDate: Optional<Date> = null;
endDate: Optional<Date> = null;
minId: Optional<number> = null;
maxId: Optional<number> = null;
country: Optional<string> = null;
locality: Optional<string> = null;
postalCode: Optional<string> = null;
orderBy: string = 'timestamp';
order: string = 'DESC';
@ -99,4 +100,4 @@ class LocationRequest {
}
}
export { LocationRequest };
export default LocationRequest;

5
src/requests/index.ts Normal file
View file

@ -0,0 +1,5 @@
import LocationRequest from "./LocationRequest";
export {
LocationRequest,
}

75
src/routes/Route.ts Normal file
View file

@ -0,0 +1,75 @@
import { Express, Request, Response } from 'express';
import { logRequest } from '../helpers/logging';
abstract class Route {
protected readonly path: string;
constructor(path: string) {
this.path = path;
}
protected static NotAllowed(res: Response) {
res.status(405).send('Method Not Allowed');
}
private getRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => {
logRequest(req);
return await this.get(req, res);
}
private postRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => {
logRequest(req);
return await this.post(req, res);
}
private putRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => {
logRequest(req);
return await this.put(req, res);
}
private deleteRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => {
logRequest(req);
return await this.delete(req, res);
}
private patchRoute: (req: Request, res: Response) => Promise<void> = async (req, res) => {
logRequest(req);
return await this.patch(req, res);
}
public get: (req: Request, res: Response) => Promise<void> = async (_, res) => {
Route.NotAllowed(res);
return Promise.resolve();
}
public post: (req: Request, res: Response) => Promise<void> = async (_, res) => {
Route.NotAllowed(res);
return Promise.resolve();
}
public put: (req: Request, res: Response) => Promise<void> = async (_, res) => {
Route.NotAllowed(res);
return Promise.resolve();
}
public delete: (req: Request, res: Response) => Promise<void> = async (_, res) => {
Route.NotAllowed(res);
return Promise.resolve();
}
public patch: (req: Request, res: Response) => Promise<void> = async (_, res) => {
Route.NotAllowed(res);
return Promise.resolve();
}
public register(app: Express) {
app.get(this.path, this.getRoute);
app.post(this.path, this.postRoute);
app.put(this.path, this.putRoute);
app.delete(this.path, this.deleteRoute);
app.patch(this.path, this.patchRoute);
}
}
export default Route;

15
src/routes/Routes.ts Normal file
View file

@ -0,0 +1,15 @@
import { Express } from 'express';
import Route from './Route';
abstract class Routes {
public abstract routes: Route[];
public register(app: Express) {
this.routes.forEach((route) => {
route.register(app);
});
}
}
export default Routes;

19
src/routes/api/Route.ts Normal file
View file

@ -0,0 +1,19 @@
import Route from '../Route';
abstract class ApiRoute extends Route {
protected version: string;
constructor(path: string, version: string) {
super(ApiRoute.toApiPath(path, version));
}
protected static toApiPath(path: string, version: string): string {
if (!path.startsWith('/')) {
path = `/${path}`;
}
return `/api/${version}${path}`;
}
}
export default ApiRoute;

9
src/routes/api/index.ts Normal file
View file

@ -0,0 +1,9 @@
import ApiV1Routes from "./v1";
import Routes from "../Routes";
class ApiRoutes extends Routes {
private v1: ApiV1Routes = new ApiV1Routes();
public routes = [...this.v1.routes];
}
export default ApiRoutes;

View file

@ -0,0 +1,37 @@
import { Request, Response } from 'express';
import { LocationRequest } from '../../../requests';
import LocationRepository from '~/repos/LocationRepository';
import ApiV1Route from './Route';
const $location: LocationRepository = globalThis.$repos.location;
class GPSData extends ApiV1Route {
constructor() {
super('/gpsdata');
}
get = async (req: Request, res: Response) => {
let query: LocationRequest
try {
query = new LocationRequest(req.query);
} catch (error) {
const e = `Error parsing query: ${error}`;
console.warn(e);
res.status(400).send(e);
return;
}
try {
const gpsData = await $location.getHistory(query);
res.json(gpsData);
} catch (error) {
const e = `Error fetching data: ${error}`;
console.error(e);
res.status(500).send(e);
}
}
}
export default GPSData;

View file

@ -0,0 +1,9 @@
import ApiRoute from '../Route';
abstract class ApiV1Route extends ApiRoute {
constructor(path: string) {
super(path, 'v1');
}
}
export default ApiV1Route;

View file

@ -0,0 +1,8 @@
import GPSData from "./GPSData";
import Routes from "../../Routes";
class ApiV1Routes extends Routes {
public routes = [new GPSData()];
}
export default ApiV1Routes;

9
src/routes/index.ts Normal file
View file

@ -0,0 +1,9 @@
import ApiRoutes from "./api";
import Routes from "./Routes";
class AllRoutes extends Routes {
private api: ApiRoutes = new ApiRoutes();
public routes = [...this.api.routes];
}
export default AllRoutes;

3
src/types.ts Normal file
View file

@ -0,0 +1,3 @@
type Optional<T> = T | null | undefined;
export { Optional };

View file

@ -21,8 +21,15 @@
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"resolveJsonModule": true,
"baseUrl": "."
"baseUrl": ".",
"rootDir": "./src",
"paths": {
"~/*": ["./src/*"]
}
},
"exclude": ["node_modules"],
"include": ["./src/**/*.ts"]
"include": [
"./src/**/*.ts",
"./src/**/*.d.ts"
]
}