lab 05
This commit is contained in:
16 files changed
+1171
No files matched your search
@@ -0,0 +1,157 @@
|
||||
:root {
|
||||
color: #172033;
|
||||
background-color: #f5f7fb;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(92%, 52rem);
|
||||
margin: 3rem auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.8rem, 5vw, 2.6rem);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.student-name {
|
||||
margin: 0.75rem 0 0;
|
||||
color: #536078;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
background-color: white;
|
||||
border: 1px solid #d8deea;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 0.75rem 2rem rgb(23 32 51 / 8%);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0 0 1rem;
|
||||
color: #536078;
|
||||
}
|
||||
|
||||
.query-label {
|
||||
display: block;
|
||||
margin-bottom: 0.4rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.query-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.8rem;
|
||||
color: #172033;
|
||||
background-color: #eef3ff;
|
||||
border: 1px solid #8398c2;
|
||||
border-radius: 0.35rem;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
font-size: 1rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.65rem 1rem;
|
||||
border: 0;
|
||||
border-radius: 0.35rem;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
color: white;
|
||||
background-color: #315fc4;
|
||||
}
|
||||
|
||||
.action-button:disabled {
|
||||
background-color: #8398c2;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.status {
|
||||
min-height: 1.2rem;
|
||||
margin: 0 0 1rem;
|
||||
color: #536078;
|
||||
}
|
||||
|
||||
.response {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.result-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.result-table th,
|
||||
.result-table td {
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid #d8deea;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.result-table th {
|
||||
color: white;
|
||||
background-color: #315fc4;
|
||||
}
|
||||
|
||||
.result-table tbody tr:nth-child(even) {
|
||||
background-color: #eef3ff;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
margin: 0;
|
||||
color: #2f7a42;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin: 0;
|
||||
color: #c43e45;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
margin: 0;
|
||||
color: #536078;
|
||||
}
|
||||
|
||||
@media (max-width: 36rem) {
|
||||
main {
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<link rel="stylesheet" href="./css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="lab-page">
|
||||
<header class="page-header">
|
||||
<h1 id="page-title"></h1>
|
||||
<p id="student-name" class="student-name"></p>
|
||||
</header>
|
||||
|
||||
<section class="panel" aria-labelledby="insert-heading">
|
||||
<h2 id="insert-heading"></h2>
|
||||
<p id="insert-description" class="description"></p>
|
||||
|
||||
<button id="insert-button" class="action-button" type="button"></button>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="query-heading">
|
||||
<h2 id="query-heading"></h2>
|
||||
<p id="query-description" class="description"></p>
|
||||
|
||||
<label id="query-label" class="query-label" for="query-input"></label>
|
||||
<textarea id="query-input" class="query-input" rows="5"></textarea>
|
||||
|
||||
<button id="query-button" class="action-button" type="button"></button>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="response-heading">
|
||||
<h2 id="response-heading"></h2>
|
||||
<p id="status" class="status" aria-live="polite"></p>
|
||||
|
||||
<div id="response" class="response" aria-live="polite"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="./js/script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,275 @@
|
||||
import { STRINGS } from "../lang/messages/en/user.js";
|
||||
|
||||
// server 2 lives on its own origin, change this one line when it moves
|
||||
const API_BASE_URL = "http://localhost:8080";
|
||||
const SQL_ROUTE = "/lab5/api/v1/sql";
|
||||
|
||||
const SELECT_PATTERN = /^select\b/i;
|
||||
const INSERT_PATTERN = /^insert\b/i;
|
||||
|
||||
class SqlStatement {
|
||||
constructor(text) {
|
||||
this.text = 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
|
||||
isSingleStatement() {
|
||||
const withoutLiterals = this.text
|
||||
.replace(/'(?:[^']|'')*'/g, "''")
|
||||
.replace(/"(?:[^"]|"")*"/g, '""');
|
||||
|
||||
return !withoutLiterals.includes(";");
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.text;
|
||||
}
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
constructor(baseUrl, route) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.route = route;
|
||||
}
|
||||
|
||||
getRouteUrl() {
|
||||
return `${this.baseUrl}${this.route}`;
|
||||
}
|
||||
|
||||
buildQueryUrl(statement) {
|
||||
return `${this.getRouteUrl()}/${encodeURIComponent(statement.toString())}`;
|
||||
}
|
||||
|
||||
select(statement) {
|
||||
return this.send(this.buildQueryUrl(statement), {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
insert(statement) {
|
||||
return this.send(this.getRouteUrl(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: statement.toString() }),
|
||||
});
|
||||
}
|
||||
|
||||
// an empty body tells server 2 to insert the four sample patients
|
||||
insertSamplePatients() {
|
||||
return this.send(this.getRouteUrl(), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
async send(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
const body = await response.text();
|
||||
|
||||
let payload;
|
||||
|
||||
try {
|
||||
payload = JSON.parse(body);
|
||||
} catch {
|
||||
payload = { success: false, error: body };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
payload: payload,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ResponseView {
|
||||
constructor(responseElement, statusElement) {
|
||||
this.responseElement = responseElement;
|
||||
this.statusElement = statusElement;
|
||||
}
|
||||
|
||||
showStatus(message) {
|
||||
this.statusElement.textContent = message;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.statusElement.textContent = "";
|
||||
this.responseElement.replaceChildren();
|
||||
}
|
||||
|
||||
showMessage(message, className) {
|
||||
const paragraph = document.createElement("p");
|
||||
paragraph.classList.add(className);
|
||||
paragraph.textContent = message;
|
||||
|
||||
this.responseElement.replaceChildren(paragraph);
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
this.showMessage(message, "error-message");
|
||||
}
|
||||
|
||||
showRows(rows) {
|
||||
if (rows.length === 0) {
|
||||
this.showMessage(STRINGS.NO_ROWS, "empty-message");
|
||||
return;
|
||||
}
|
||||
|
||||
this.responseElement.replaceChildren(this.buildTable(rows));
|
||||
}
|
||||
|
||||
buildTable(rows) {
|
||||
const table = document.createElement("table");
|
||||
const head = document.createElement("thead");
|
||||
const body = document.createElement("tbody");
|
||||
const columns = Object.keys(rows[0]);
|
||||
|
||||
table.classList.add("result-table");
|
||||
head.appendChild(this.buildRow(columns, "th", (column) => column));
|
||||
|
||||
for (const row of rows) {
|
||||
body.appendChild(this.buildRow(columns, "td", (column) => row[column]));
|
||||
}
|
||||
|
||||
table.append(head, body);
|
||||
return table;
|
||||
}
|
||||
|
||||
buildRow(columns, cellTag, getValue) {
|
||||
const tableRow = document.createElement("tr");
|
||||
|
||||
for (const column of columns) {
|
||||
const cell = document.createElement(cellTag);
|
||||
cell.textContent = this.formatValue(getValue(column));
|
||||
tableRow.appendChild(cell);
|
||||
}
|
||||
|
||||
return tableRow;
|
||||
}
|
||||
|
||||
formatValue(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return STRINGS.NULL_VALUE;
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
class LabPage {
|
||||
constructor(api) {
|
||||
this.api = api;
|
||||
|
||||
this.insertButton = document.getElementById("insert-button");
|
||||
this.queryButton = document.getElementById("query-button");
|
||||
this.queryInput = document.getElementById("query-input");
|
||||
|
||||
this.view = new ResponseView(
|
||||
document.getElementById("response"),
|
||||
document.getElementById("status")
|
||||
);
|
||||
}
|
||||
|
||||
init() {
|
||||
document.title = STRINGS.PAGE_TITLE;
|
||||
|
||||
document.getElementById("page-title").textContent = STRINGS.PAGE_TITLE;
|
||||
document.getElementById("student-name").textContent = STRINGS.STUDENT_NAME;
|
||||
document.getElementById("insert-heading").textContent = STRINGS.INSERT_HEADING;
|
||||
document.getElementById("insert-description").textContent = STRINGS.INSERT_DESCRIPTION;
|
||||
document.getElementById("query-heading").textContent = STRINGS.QUERY_HEADING;
|
||||
document.getElementById("query-description").textContent = STRINGS.QUERY_DESCRIPTION;
|
||||
document.getElementById("query-label").textContent = STRINGS.QUERY_LABEL;
|
||||
document.getElementById("response-heading").textContent = STRINGS.RESPONSE_HEADING;
|
||||
|
||||
this.insertButton.textContent = STRINGS.INSERT_BUTTON;
|
||||
this.queryButton.textContent = STRINGS.QUERY_BUTTON;
|
||||
this.queryInput.placeholder = STRINGS.QUERY_PLACEHOLDER;
|
||||
|
||||
this.view.showMessage(STRINGS.RESPONSE_EMPTY, "empty-message");
|
||||
|
||||
this.insertButton.addEventListener("click", () => this.handleInsert());
|
||||
this.queryButton.addEventListener("click", () => this.handleQuery());
|
||||
}
|
||||
|
||||
handleInsert() {
|
||||
this.sendRequest(() => this.api.insertSamplePatients());
|
||||
}
|
||||
|
||||
handleQuery() {
|
||||
const statement = new SqlStatement(this.queryInput.value);
|
||||
|
||||
if (statement.isEmpty()) {
|
||||
this.view.clear();
|
||||
this.view.showError(STRINGS.EMPTY_QUERY);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!statement.isSingleStatement()) {
|
||||
this.view.clear();
|
||||
this.view.showError(STRINGS.SINGLE_STATEMENT_ONLY);
|
||||
return;
|
||||
}
|
||||
|
||||
// reads go out as GET, inserts as POST. anything else still goes out
|
||||
// as GET so the read only database user is the one that rejects it.
|
||||
if (statement.isInsert()) {
|
||||
this.sendRequest(() => this.api.insert(statement));
|
||||
} else {
|
||||
this.sendRequest(() => this.api.select(statement));
|
||||
}
|
||||
}
|
||||
|
||||
async sendRequest(request) {
|
||||
this.view.clear();
|
||||
this.view.showStatus(STRINGS.SENDING);
|
||||
this.setBusy(true);
|
||||
|
||||
try {
|
||||
this.showResult(await request());
|
||||
} catch (error) {
|
||||
this.view.showStatus("");
|
||||
this.view.showError(STRINGS.NETWORK_ERROR + error.message);
|
||||
} finally {
|
||||
this.setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
showResult(result) {
|
||||
const payload = result.payload;
|
||||
|
||||
if (!result.ok || payload.success === false) {
|
||||
this.view.showStatus("");
|
||||
this.view.showError(STRINGS.SERVER_ERROR + (payload.error || result.status));
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.data)) {
|
||||
this.view.showStatus(STRINGS.ROWS_RETURNED + payload.data.length);
|
||||
this.view.showRows(payload.data);
|
||||
return;
|
||||
}
|
||||
|
||||
this.view.showStatus(STRINGS.ROWS_INSERTED + (payload.rowCount ?? 0));
|
||||
this.view.showMessage(payload.message, "success-message");
|
||||
}
|
||||
|
||||
setBusy(isBusy) {
|
||||
this.insertButton.disabled = isBusy;
|
||||
this.queryButton.disabled = isBusy;
|
||||
}
|
||||
}
|
||||
|
||||
const api = new ApiClient(API_BASE_URL, SQL_ROUTE);
|
||||
new LabPage(api).init();
|
||||
@@ -0,0 +1,28 @@
|
||||
export const STRINGS = {
|
||||
PAGE_TITLE: "Lab 5: DB Intro",
|
||||
STUDENT_NAME: "Braeden Sowinski",
|
||||
|
||||
INSERT_HEADING: "Insert Patients",
|
||||
INSERT_DESCRIPTION: "Sends a POST request to server 2 that inserts the four sample patients. Press it as often as you like, the table keeps growing.",
|
||||
INSERT_BUTTON: "Insert Rows",
|
||||
|
||||
QUERY_HEADING: "Run a Query",
|
||||
QUERY_DESCRIPTION: "SELECT statements are sent with GET, INSERT statements are sent with POST. Anything else is still sent, and the read only database user is what refuses it.",
|
||||
QUERY_LABEL: "SQL query",
|
||||
QUERY_PLACEHOLDER: "SELECT * FROM patient",
|
||||
QUERY_BUTTON: "Send Query",
|
||||
|
||||
RESPONSE_HEADING: "Server Response",
|
||||
RESPONSE_EMPTY: "No request has been sent yet.",
|
||||
|
||||
SENDING: "Sending request...",
|
||||
ROWS_RETURNED: "Rows returned: ",
|
||||
ROWS_INSERTED: "Rows inserted: ",
|
||||
NO_ROWS: "The query returned no rows.",
|
||||
NULL_VALUE: "NULL",
|
||||
|
||||
EMPTY_QUERY: "Please enter a SQL query first.",
|
||||
SINGLE_STATEMENT_ONLY: "Please send one statement at a time.",
|
||||
SERVER_ERROR: "Server responded with an error: ",
|
||||
NETWORK_ERROR: "Could not reach the API server: ",
|
||||
};
|
||||
Reference in new issue
Block a user