diff --git a/server/Maybe.ts b/server/Maybe.ts index 694c7ac..6a1ff74 100644 --- a/server/Maybe.ts +++ b/server/Maybe.ts @@ -1,2 +1,2 @@ -export type Maybe = Just | { just?: never, error: { message: string }}; -export type Just = { just: T, error?: never }; +export type Maybe = Just | { just?: never; error: { message: string } }; +export type Just = { just: T; error?: never }; diff --git a/server/StoccaTreRequest.ts b/server/StoccaTreRequest.ts index a510e1c..6401dc3 100644 --- a/server/StoccaTreRequest.ts +++ b/server/StoccaTreRequest.ts @@ -1,7 +1,22 @@ export type HttpMethod = "POST" | "GET" | "PUT" | "DELETE"; -export default interface StoccaTreRequest { - method: HttpMethod, - route: string, - body: string | null, +export type RouteDefinition = { + pattern: RegExp; + method: HttpMethod; +}; + +export default class StoccaTreRequest { + constructor( + public method: HttpMethod, + public route: string, + public body: string | null, + ) {} + + match(route: RouteDefinition): RegExpExecArray | null { + const patternResult = route.pattern.exec(this.route); + if (route.method !== this.method) { + return null; + } + return patternResult; + } } diff --git a/server/StoccaTreRequestHandler.ts b/server/StoccaTreRequestHandler.ts index a37f553..2e55595 100644 --- a/server/StoccaTreRequestHandler.ts +++ b/server/StoccaTreRequestHandler.ts @@ -1,7 +1,7 @@ import StoccaTreRequest from "./StoccaTreRequest.ts"; -import {Maybe} from "./Maybe.ts"; -import {JSONObject} from "./JSON.ts"; +import { Maybe } from "./Maybe.ts"; +import { JSONObject } from "./JSON.ts"; export default interface StoccaTreRequestHandler { - handleRequest(request: StoccaTreRequest): Promise>, -} \ No newline at end of file + handleRequest(request: StoccaTreRequest): Promise>; +} diff --git a/server/config.json b/server/config.json deleted file mode 100644 index faccf9b..0000000 --- a/server/config.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "username": "root", - "port": 8080, - "hostname": "127.0.0.1", - "password": "sekna123jk" -} \ No newline at end of file diff --git a/server/config.ts b/server/config.ts index a633c94..96240e4 100644 --- a/server/config.ts +++ b/server/config.ts @@ -1,6 +1,17 @@ -type ServerConfig = { - username: string, - hostname: string, - password: string, - port: number, -} +const config = { + username: Deno.env.get("DB_USER") ?? "postgres", + hostname: Deno.env.get("DB_HOST") ?? "localhost", + password: Deno.env.get("DB_PW") ?? "", + dbPort: Number(Deno.env.get("DB_PORT") ?? 5432), + port: Number(Deno.env.get("PORT") ?? 8080), +}; + +console.log(`ENV: +db username: ${config.username} +db pass: ****** +db port: ${config.dbPort} +server port: ${config.port} +server hostname: ${config.hostname} +`); + +export default config; diff --git a/server/database.ts b/server/database.ts index 01ed4d8..5636afd 100644 --- a/server/database.ts +++ b/server/database.ts @@ -1,38 +1,41 @@ -import { Client } from "https://deno.land/x/mysql/mod.ts"; -import config from "./config.json" assert { type: "json" }; +import { Client } from "postgres"; +import config from "./config.ts"; import {Maybe} from "./Maybe.ts"; +import {JSONObject} from "./JSON.ts"; export type WithoutId = Omit; + export interface StoccaTreDbConn { - query(query: string): Promise>; + query(query: string): Promise>, } -export default async function createNewDbConnection(): Promise { - const mysqlClient = await new Client().connect({ + +export default function createNewDbConnection(): StoccaTreDbConn { + const postgresClient = new Client({ hostname: config.hostname, - username: config.username, - db: "stocca_tre", password: config.password, + user: config.username, + database: "stocca_tre", + port: config.port, }); + await postgresClient.connect(); return { - query: async (query: string): Promise> => { - let errMessage: string; + async queryMany(query: string): Promise> { try { - const result = await mysqlClient.query(query); + const result = await postgresClient.queryArray(query); return { - just: result as T, + just: { + rows: result.rows, + count: result.rowCount ?? NaN, + }, }; } catch (e: unknown) { - if (e && typeof (e as { message?: any }).message === "string") { - errMessage = (e as { message: string}).message; - } else { - errMessage = "An unknown error occurred in the database."; - } } - return { - error: { - message: errMessage - }, - }; + }, + queryOne(query: string): Promise> { + try { + const result = await postgresClient.queryObject(query); + return { just: result }; + } } } } diff --git a/server/import_map.json b/server/import_map.json index 899e37c..7c0cdd1 100644 --- a/server/import_map.json +++ b/server/import_map.json @@ -1,4 +1,7 @@ { - "imports": { - } + "imports": { + "zod": "https://deno.land/x/zod@v3.17.3/mod.ts", + "postgres": "https://deno.land/x/postgres@v0.16.1/mod.ts", + "bcrypt": "https://deno.land/x/bcrypt@v0.4.0/mod.ts" + } } diff --git a/server/main.ts b/server/main.ts index 9b2f7fc..3569f1f 100644 --- a/server/main.ts +++ b/server/main.ts @@ -1,14 +1,14 @@ -import createNewDbConnection, {StoccaTreDbConn} from "./database.ts"; +import createNewDbConnection, { StoccaTreDbConn } from "./database.ts"; import * as resources from "./resources/main.ts"; -import config from "./config.json" assert { type: "json" }; -import StoccaTreRequest, {HttpMethod} from "./StoccaTreRequest.ts"; +import config from "./config.ts"; +import StoccaTreRequest, { HttpMethod } from "./StoccaTreRequest.ts"; import StoccaTreRequestHandler from "./StoccaTreRequestHandler.ts"; -import {JSONObject} from "./JSON.ts"; +import { JSONObject } from "./JSON.ts"; type StoccaTreApiBody = { - data: JSONObject, - error?: string, -} + data: JSONObject; + error?: string; +}; function newStoccaTreApiBody(): StoccaTreApiBody { return { @@ -17,22 +17,22 @@ function newStoccaTreApiBody(): StoccaTreApiBody { } type StoccaTreServer = StoccaTreRequestHandler & { - dbConnection: StoccaTreDbConn, -} + dbConnection: StoccaTreDbConn; +}; async function processRequest(server: StoccaTreServer, requestEvent: Deno.RequestEvent) { const route = /^https?:\/\/[^\/]*(\/.*)$/.exec(requestEvent.request.url)?.[1] ?? "/"; - const requestBody = (await requestEvent.request.body?.getReader().read())?.value?.toString() ?? null; - console.log(requestBody); + const requestBody = (await (await requestEvent.request.blob()).text()) ?? null; const method: HttpMethod = requestEvent.request.method as HttpMethod; const body: StoccaTreApiBody = newStoccaTreApiBody(); - const result = await server.handleRequest({ - route, - body: requestBody, - method, - }); + const result = await server.handleRequest(new StoccaTreRequest(method, route, requestBody)); if (result.error) { - await requestEvent.respondWith(Response.json({ ...body, error: `Internal server error: ${result.error.message}` }, { status: 500 })); + await requestEvent.respondWith( + Response.json({ + ...body, + error: `Internal server error: ${result.error.message}`, + }, { status: 500 }), + ); } else { body.data = result.just; await requestEvent.respondWith(Response.json(body, { status: 200 })); @@ -41,10 +41,11 @@ async function processRequest(server: StoccaTreServer, requestEvent: Deno.Reques const stoccaTreListener = Deno.listen({ port: config.port ?? 8080 }); -console.log(`Stocca Tre Server is running. Access it at: http://localhost:${ config.port }/`); +console.log(`Stocca Tre Server is running. Access it at: http://localhost:${config.port}/`); -const database = await createNewDbConnection(); +const database = createNewDbConnection(); const ingredientResource = new resources.IngredientResource(database); +const userResource = new resources.UserResource(database); const stoccaTreServer: StoccaTreServer = { dbConnection: database, handleRequest: (request: StoccaTreRequest) => ingredientResource.handleRequest(request), diff --git a/server/package.json b/server/package.json deleted file mode 100644 index 3aae7f5..0000000 --- a/server/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "stocca-tre-server", - "version": "0.0.1", - "scripts": { - "start": "deno run --allow-net --import-map=import_map.json main.ts" - }, - "description": "Backend for StoccaTre", - "author": "Daniel Ledda", - "license": "MIT" -} diff --git a/server/resources/ingredient/IngredientCollection.ts b/server/resources/ingredient/IngredientCollection.ts index 0b6bf3f..c7d4a89 100644 --- a/server/resources/ingredient/IngredientCollection.ts +++ b/server/resources/ingredient/IngredientCollection.ts @@ -1,31 +1,42 @@ -import {StoccaTreDbConn, WithoutId} from "../../database.ts"; -import {IngredientModel} from "./IngredientModel.ts"; -import {Maybe} from "../../Maybe.ts"; +import { StoccaTreDbConn, WithoutId } from "../../database.ts"; +import { IngredientModel } from "./IngredientModel.ts"; +import { Maybe } from "../../Maybe.ts"; export default class IngredientCollection { - private dbConnection: StoccaTreDbConn; + private db: StoccaTreDbConn; private mapById: Map = new Map(); - private allGotten = false; constructor(database: StoccaTreDbConn) { - this.dbConnection = database; + this.db = database; } - async addIngredient(ingredient: WithoutId): Promise { - return await this.dbConnection.query( - `INSERT INTO ingredients (id, name, displayName, displayNameDE) VALUES (NULL, '${ingredient.name}', '${ingredient.displayName}', '${ingredient.displayNameDE}');` + async addIngredient(ingredient: WithoutId): Promise> { + return await this.db.query(sql => + sql`INSERT INTO ingredients ${ sql(ingredient) }` ); } + async getById(id: number): Promise> { + const found = this.mapById.get(id); + if (found) { + return { just: found }; + } + const result = await this.db.query(sql => sql`SELECT * FROM ingredients WHERE id is ${ id }`); + if (result.error) { + return result; + } + const ingredient = result.just[0]; + this.mapById.set(ingredient.id, ingredient); + return { just: ingredient }; + } async getAllIngredients(): Promise>> { - if (!this.allGotten) { - const result = await this.dbConnection.query("SELECT * FROM ingredients"); - if (!result.error) { - result.just.forEach((ingredient: IngredientModel) => this.mapById.set(ingredient.id, ingredient)); - } else { - return result; - } - this.allGotten = true; + const result: Maybe = await this.db.query(sql => + sql`SELECT * FROM ingredients` + ); + if (!result.error) { + result.just.forEach(ingredient => this.mapById.set(ingredient.id, ingredient)); + } else { + return result; } return { just: this.mapById.values(), diff --git a/server/resources/ingredient/IngredientModel.ts b/server/resources/ingredient/IngredientModel.ts index 27dd65d..69b9a4d 100644 --- a/server/resources/ingredient/IngredientModel.ts +++ b/server/resources/ingredient/IngredientModel.ts @@ -1,6 +1,12 @@ -export type IngredientModel = { - id: number, - name: string, - displayName: string, - displayNameDE: string, -}; +import { z } from "zod"; + +export const IngredientSchema = z.object({ + id: z.number(), + name: z.string().nonempty(), + displayName: z.string().nonempty(), + displayNameDE: z.string().nonempty(), +}); + +export const IngredientSchemaWithoutId = IngredientSchema.omit({ id: true }); + +export type IngredientModel = z.infer; \ No newline at end of file diff --git a/server/resources/ingredient/IngredientResource.ts b/server/resources/ingredient/IngredientResource.ts index 93d07f7..109bc07 100644 --- a/server/resources/ingredient/IngredientResource.ts +++ b/server/resources/ingredient/IngredientResource.ts @@ -1,45 +1,48 @@ import {StoccaTreDbConn} from "../../database.ts"; import IngredientCollection from "./IngredientCollection.ts"; -import StoccaTreRequest from "../../StoccaTreRequest.ts"; +import StoccaTreRequest, {RouteDefinition} from "../../StoccaTreRequest.ts"; import {Maybe} from "../../Maybe.ts"; import {JSONObject} from "../../JSON.ts"; -import {IngredientModel} from "./IngredientModel.ts"; +import {IngredientSchemaWithoutId} from "./IngredientModel.ts"; export default class IngredientResource { private dbConnection: StoccaTreDbConn; private collection: IngredientCollection; + private routes: Readonly> = { + Add: { + pattern: /\/add/, + method: "POST" + }, + GetAll: { + pattern: /\/all/, + method: "GET" + }, + } as const; constructor(dbConnection: StoccaTreDbConn) { this.dbConnection = dbConnection; this.collection = new IngredientCollection(dbConnection); } - static isIngredient(json: JSONObject): json is IngredientModel { - if (json) { - return true; - } - return false; - } - async handleRequest(request: StoccaTreRequest): Promise> { - switch (request.route) { - case "/add": - if (request.method === "POST") { - const ingredient = JSON.parse(request.body ?? ""); - return await this.collection.addIngredient(JSON.parse(request.body)); - - - } - break; - case "/all": - return await this.allIngredients(request); - default: - break; + if (request.match(this.routes.Add)) { + return await this.addIngredient(request); } - return { error: {message: "Invalid route" }}; + if (request.match(this.routes.GetAll)) { + return await this.allIngredients(request); + } + return { error: { message: "Invalid route" }}; } - async allIngredients(request: StoccaTreRequest): Promise> { + private async addIngredient(request: StoccaTreRequest): Promise> { + const ingredient = IngredientSchemaWithoutId.safeParse(JSON.parse(request.body ?? "{}")); + if (!ingredient.success) { + return { error: new Error("Ingredient was malformed.") }; + } + return await this.collection.addIngredient(ingredient.data); + } + + private async allIngredients(request: StoccaTreRequest): Promise> { const getAllIngredientResult = await this.collection.getAllIngredients(); if (getAllIngredientResult.error) { return getAllIngredientResult; diff --git a/server/resources/main.ts b/server/resources/main.ts index 6954655..dc13ef2 100644 --- a/server/resources/main.ts +++ b/server/resources/main.ts @@ -1 +1,2 @@ +export * from "./user/main.ts"; export * from "./ingredient/main.ts"; diff --git a/server/resources/user/UserCollection.ts b/server/resources/user/UserCollection.ts new file mode 100644 index 0000000..0422050 --- /dev/null +++ b/server/resources/user/UserCollection.ts @@ -0,0 +1,50 @@ +import * as bcrypt from "bcrypt"; +import { StoccaTreDbConn, WithoutId } from "../../database.ts"; +import { UserModel } from "./UserModel.ts"; +import { Maybe } from "../../Maybe.ts"; + +export default class UserCollection { + private db: StoccaTreDbConn; + private mapById: Map = new Map(); + + constructor(database: StoccaTreDbConn) { + this.db = database; + } + + async addUser(user: WithoutId): Promise> { + let hash: string; + try { + hash = await bcrypt.hash(user.password); + } catch (e: unknown) { + const error = e as { message?: string }; + if (typeof error.message === "string") { + return { error: { message: error.message }}; + } + return { error: new Error("Failed to create user") }; + } + user.password = hash; + const result: Maybe = await this.db.query(sql => sql` + INSERT INTO users ${ sql(user, "displayName") } + RETURNING * + `); + if (result.error) { + return result; + } + return { + just: { insertedId: result.just[0]?.id ?? NaN } + } + + } + + async getAllUsers(): Promise> { + const result = await this.db.query((sql) => sql`SELECT * FROM users`); + if (!result.error) { + result.just.forEach(user => this.mapById.set(user.id, user)); + } else { + return result; + } + return { + just: Array.from(this.mapById.values()), + }; + } +} diff --git a/server/resources/user/UserModel.ts b/server/resources/user/UserModel.ts new file mode 100644 index 0000000..dda2dce --- /dev/null +++ b/server/resources/user/UserModel.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const UserSchema = z.object({ + id: z.number(), + displayName: z.string(), + email: z.string().email(), + password: z.string().min(256).max(256), +}); + +export const UserSchemaWithoutId = UserSchema.omit({ id: true }); + +export type UserModel = z.infer; \ No newline at end of file diff --git a/server/resources/user/UserResource.ts b/server/resources/user/UserResource.ts new file mode 100644 index 0000000..0e13427 --- /dev/null +++ b/server/resources/user/UserResource.ts @@ -0,0 +1,56 @@ +import {StoccaTreDbConn} from "../../database.ts"; +import StoccaTreRequest, {RouteDefinition} from "../../StoccaTreRequest.ts"; +import {Maybe} from "../../Maybe.ts"; +import {JSONObject} from "../../JSON.ts"; +import UserCollection from "./UserCollection.ts"; +import {UserSchemaWithoutId} from "./UserModel.ts"; + +export default class UserResource { + private dbConnection: StoccaTreDbConn; + private collection: UserCollection; + private routes: Readonly> = { + Add: { + pattern: /\/add/, + method: "POST" + }, + GetAll: { + pattern: /\/all/, + method: "GET" + }, + } as const; + + constructor(dbConnection: StoccaTreDbConn) { + this.dbConnection = dbConnection; + this.collection = new UserCollection(dbConnection); + } + + async handleRequest(request: StoccaTreRequest): Promise> { + if (request.match(this.routes.Add)) { + return await this.addUser(request); + } + if (request.match(this.routes.GetAll)) { + return await this.allUsers(request); + } + return { error: { message: "Invalid route" }}; + } + + private async addUser(request: StoccaTreRequest): Promise> { + const user = UserSchemaWithoutId.safeParse(JSON.parse(request.body ?? "{}")); + if (!user.success) { + return { error: new Error("Ingredient was malformed.") }; + } + return await this.collection.addUser(user.data); + } + + private async allUsers(request: StoccaTreRequest): Promise> { + const allUsers = await this.collection.getAllUsers(); + if (allUsers.error) { + return allUsers; + } + return { + just: { + ingredients: Array.from(allUsers.just), + }, + }; + } +} diff --git a/server/resources/user/main.ts b/server/resources/user/main.ts new file mode 100644 index 0000000..93a4084 --- /dev/null +++ b/server/resources/user/main.ts @@ -0,0 +1,3 @@ +export * from "./UserModel.ts"; +export * from "./UserCollection.ts"; +export * from "./UserResource.ts"; diff --git a/server/start.sh b/server/start.sh new file mode 100644 index 0000000..863833e --- /dev/null +++ b/server/start.sh @@ -0,0 +1 @@ +DB_USER=postgres && DB_PASS=postgres && deno run --allow-env --allow-net --import-map=import_map.json main.ts