lab 05
This commit is contained in:
16 files changed
+1171
No files matched your search
@@ -0,0 +1,200 @@
|
||||
import http from "node:http";
|
||||
import { SqlStatement } from "./SqlStatement.js";
|
||||
import { MESSAGES } from "../lang/messages/en/user.js";
|
||||
|
||||
const SQL_ROUTE = "/lab5/api/v1/sql";
|
||||
|
||||
export class ApiServer {
|
||||
constructor(port, patients) {
|
||||
this.port = port;
|
||||
this.patients = patients;
|
||||
this.server = http.createServer((request, response) => this.handle(request, response));
|
||||
}
|
||||
|
||||
start() {
|
||||
this.server.listen(this.port, () => {
|
||||
console.log(MESSAGES.LISTENING + this.port);
|
||||
});
|
||||
}
|
||||
|
||||
setCorsHeaders(response) {
|
||||
// the client is on another origin by design, so it needs to be let through
|
||||
response.setHeader("Access-Control-Allow-Origin", "*");
|
||||
response.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||
response.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
}
|
||||
|
||||
sendJson(response, status, payload) {
|
||||
this.setCorsHeaders(response);
|
||||
response.writeHead(status, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
sendError(response, status, message) {
|
||||
this.sendJson(response, status, { success: false, error: message });
|
||||
}
|
||||
|
||||
readBody(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
|
||||
request.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
|
||||
request.on("end", () => resolve(body));
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
// the query arrives as the tail of the path, url encoded and
|
||||
// sometimes wrapped in the quotes the lab sheet shows
|
||||
extractSql(pathname) {
|
||||
const prefix = `${SQL_ROUTE}/`;
|
||||
|
||||
if (!pathname.startsWith(prefix)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let sql = pathname.slice(prefix.length);
|
||||
|
||||
try {
|
||||
sql = decodeURIComponent(sql);
|
||||
} catch {
|
||||
// a malformed escape stays as typed, the database will reject it
|
||||
}
|
||||
|
||||
sql = sql.trim();
|
||||
|
||||
if (this.isQuoted(sql)) {
|
||||
sql = sql.slice(1, -1).trim();
|
||||
}
|
||||
|
||||
return sql;
|
||||
}
|
||||
|
||||
isQuoted(text) {
|
||||
if (text.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (text.startsWith('"') && text.endsWith('"'))
|
||||
|| (text.startsWith("'") && text.endsWith("'"));
|
||||
}
|
||||
|
||||
async handleGet(request, response, url) {
|
||||
const statement = new SqlStatement(
|
||||
this.extractSql(url.pathname) || url.searchParams.get("query")
|
||||
);
|
||||
|
||||
if (statement.isEmpty()) {
|
||||
return this.sendError(response, 400, MESSAGES.MISSING_QUERY);
|
||||
}
|
||||
|
||||
if (!statement.isSingleStatement()) {
|
||||
return this.sendError(response, 400, MESSAGES.SINGLE_STATEMENT_ONLY);
|
||||
}
|
||||
|
||||
// anything that is not a read is still handed to the reader user on
|
||||
// purpose, so the database privileges are what refuse it.
|
||||
try {
|
||||
const rows = await this.patients.select(statement);
|
||||
|
||||
this.sendJson(response, 200, {
|
||||
success: true,
|
||||
query: statement.toString(),
|
||||
data: rows,
|
||||
});
|
||||
} catch (error) {
|
||||
this.sendJson(response, 403, {
|
||||
success: false,
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async handlePost(request, response, url) {
|
||||
if (url.pathname !== SQL_ROUTE && url.pathname !== `${SQL_ROUTE}/`) {
|
||||
return this.sendError(response, 404, MESSAGES.INVALID_ENDPOINT);
|
||||
}
|
||||
|
||||
const body = await this.readBody(request);
|
||||
const statement = new SqlStatement(this.parseQuery(body));
|
||||
|
||||
try {
|
||||
// an empty body means the insert button, which uses the fixed rows
|
||||
const result = statement.isEmpty()
|
||||
? await this.patients.insertSamplePatients()
|
||||
: await this.insertCustom(response, statement);
|
||||
|
||||
if (result === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendJson(response, 200, {
|
||||
success: true,
|
||||
message: MESSAGES.ROWS_INSERTED,
|
||||
rowCount: result.affectedRows,
|
||||
});
|
||||
} catch (error) {
|
||||
this.sendJson(response, 403, {
|
||||
success: false,
|
||||
error: error.message,
|
||||
code: error.code,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// the writer user is the only one that can change data, so this route
|
||||
// refuses anything that is not a single insert before it gets there.
|
||||
async insertCustom(response, statement) {
|
||||
if (!statement.isSingleStatement()) {
|
||||
this.sendError(response, 400, MESSAGES.SINGLE_STATEMENT_ONLY);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!statement.isInsert()) {
|
||||
this.sendError(response, 403, MESSAGES.INSERT_ONLY);
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.patients.insert(statement);
|
||||
}
|
||||
|
||||
parseQuery(body) {
|
||||
if (!body.trim()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(body).query ?? "";
|
||||
} catch {
|
||||
return body.trim();
|
||||
}
|
||||
}
|
||||
|
||||
handle(request, response) {
|
||||
if (request.method === "OPTIONS") {
|
||||
this.setCorsHeaders(response);
|
||||
response.writeHead(204);
|
||||
return response.end();
|
||||
}
|
||||
|
||||
const url = new URL(request.url, `http://${request.headers.host}`);
|
||||
|
||||
if (!url.pathname.startsWith(SQL_ROUTE)) {
|
||||
return this.sendError(response, 404, MESSAGES.INVALID_ENDPOINT);
|
||||
}
|
||||
|
||||
if (request.method === "GET") {
|
||||
return this.handleGet(request, response, url);
|
||||
}
|
||||
|
||||
if (request.method === "POST") {
|
||||
return this.handlePost(request, response, url);
|
||||
}
|
||||
|
||||
return this.sendError(response, 405, MESSAGES.METHOD_NOT_ALLOWED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import mysql from "mysql2/promise";
|
||||
|
||||
export class Database {
|
||||
constructor(config) {
|
||||
this.readerPool = this.createPool(config, config.readerUser, config.readerPassword);
|
||||
this.writerPool = this.createPool(config, config.writerUser, config.writerPassword);
|
||||
}
|
||||
|
||||
createPool(config, user, password) {
|
||||
return mysql.createPool({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
database: config.database,
|
||||
user: user,
|
||||
password: password,
|
||||
ssl: config.ssl,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 5,
|
||||
|
||||
// hand back DATETIME exactly as it is stored instead of a JS Date,
|
||||
// which would otherwise be shifted into UTC on the way to the client
|
||||
dateStrings: true,
|
||||
|
||||
// chained statements never even reach the database
|
||||
multipleStatements: false,
|
||||
});
|
||||
}
|
||||
|
||||
async testConnections() {
|
||||
const [reader] = await this.readerPool.query("SELECT CURRENT_USER() AS user;");
|
||||
const [writer] = await this.writerPool.query("SELECT CURRENT_USER() AS user;");
|
||||
|
||||
return {
|
||||
reader: reader[0].user,
|
||||
writer: writer[0].user,
|
||||
};
|
||||
}
|
||||
|
||||
// reads go through the user that only holds SELECT
|
||||
async read(statement) {
|
||||
const [rows] = await this.readerPool.query(statement.toString());
|
||||
return rows;
|
||||
}
|
||||
|
||||
// writes go through the user that holds SELECT, INSERT and CREATE, nothing else
|
||||
async write(sql) {
|
||||
const [result] = await this.writerPool.query(sql);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
const SAMPLE_PATIENTS = `
|
||||
INSERT INTO patient (name, dateOfBirth) VALUES
|
||||
('Sara Brown', '1901-01-01'),
|
||||
('John Smith', '1941-01-01'),
|
||||
('Jack Ma', '1961-01-30'),
|
||||
('Elon Musk', '1999-01-01');
|
||||
`;
|
||||
|
||||
const CREATE_PATIENT_TABLE = `
|
||||
CREATE TABLE IF NOT EXISTS patient (
|
||||
patientid INT(11) NOT NULL AUTO_INCREMENT,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
dateOfBirth DATETIME NOT NULL,
|
||||
PRIMARY KEY (patientid)
|
||||
) ENGINE=InnoDB;
|
||||
`;
|
||||
|
||||
export class PatientRepository {
|
||||
constructor(database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
// called before every insert, so a dropped table comes back on the next press
|
||||
async ensureTable() {
|
||||
await this.database.write(CREATE_PATIENT_TABLE);
|
||||
}
|
||||
|
||||
async insertSamplePatients() {
|
||||
await this.ensureTable();
|
||||
return this.database.write(SAMPLE_PATIENTS);
|
||||
}
|
||||
|
||||
async insert(statement) {
|
||||
await this.ensureTable();
|
||||
return this.database.write(statement.toString());
|
||||
}
|
||||
|
||||
async select(statement) {
|
||||
return this.database.read(statement);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
const SELECT_PATTERN = /^select\b/i;
|
||||
const INSERT_PATTERN = /^insert\b/i;
|
||||
|
||||
export class SqlStatement {
|
||||
constructor(text) {
|
||||
// a trailing semicolon is harmless, it only makes the url uglier
|
||||
this.text = String(text ?? "").trim().replace(/;+$/, "").trim();
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return this.text.length === 0;
|
||||
}
|
||||
|
||||
isSelect() {
|
||||
return SELECT_PATTERN.test(this.text);
|
||||
}
|
||||
|
||||
isInsert() {
|
||||
return INSERT_PATTERN.test(this.text);
|
||||
}
|
||||
|
||||
// a semicolon inside a quoted literal is data, not a second statement,
|
||||
// so blank the literals out before looking for one.
|
||||
isSingleStatement() {
|
||||
const withoutLiterals = this.text
|
||||
.replace(/'(?:[^']|'')*'/g, "''")
|
||||
.replace(/"(?:[^"]|"")*"/g, '""');
|
||||
|
||||
return !withoutLiterals.includes(";");
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.text;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user