feat: things are happening...

This commit is contained in:
Daniel Ledda
2022-07-10 23:07:51 +02:00
parent ba68f953f0
commit fb9f78caf7
22 changed files with 448 additions and 207 deletions

View File

@@ -1,2 +0,0 @@
export type Maybe<T> = Just<T> | { just?: never; error: { message: string } };
export type Just<T> = { just: T; error?: never };

35
server/Result.ts Normal file
View File

@@ -0,0 +1,35 @@
export class StoccaTreError {
public message: string;
private statusCode: number = 500;
constructor(message: string, status: number = 500) {
this.message = message;
this.statusCode = 500;
this.status = status;
}
qualified(qualification: string): this {
const insertionPoint = qualification.indexOf("$err");
this.message = insertionPoint !== -1
? `${qualification.slice(0, insertionPoint)}${this.message}${qualification.slice(insertionPoint + 4)}`
: `${qualification}${this.message}`;
return this;
}
get status(): number {
return this.statusCode;
}
set status(code: number) {
if (code >= 100 && code < 600) {
this.statusCode = code;
}
}
withStatus(code: number): this {
this.status = code;
return this;
}
}
export type Result<T> = [error: undefined, just: T] | [error: StoccaTreError, just?: undefined];

View File

@@ -1,22 +1,25 @@
import Http = Deno.errors.Http;
import {JSONObject} from "./JSON.ts";
export type HttpMethod = "POST" | "GET" | "PUT" | "DELETE";
export type RouteDefinition = {
pattern: RegExp;
method: HttpMethod;
method?: HttpMethod;
};
export default class StoccaTreRequest {
constructor(
public method: HttpMethod,
public route: string,
public body: string | null,
public body: JSONObject | null,
) {}
match(route: RouteDefinition): RegExpExecArray | null {
match(route: RouteDefinition): RegExpExecArray | false {
const patternResult = route.pattern.exec(this.route);
if (route.method !== this.method) {
return null;
if (route.method && route.method !== this.method) {
return false;
}
return patternResult;
return patternResult ?? false;
}
}

View File

@@ -1,7 +1,7 @@
import StoccaTreRequest from "./StoccaTreRequest.ts";
import { Maybe } from "./Maybe.ts";
import { Result } from "./Result.ts";
import { JSONObject } from "./JSON.ts";
export default interface StoccaTreRequestHandler {
handleRequest(request: StoccaTreRequest): Promise<Maybe<JSONObject>>;
handleRequest(request: StoccaTreRequest): Promise<Result<JSONObject> | null>;
}

35
server/StoccaTreServer.ts Normal file
View File

@@ -0,0 +1,35 @@
import StoccaTreRequestHandler from "./StoccaTreRequestHandler.ts";
import StoccaTreRequest, { RouteDefinition } from "./StoccaTreRequest.ts";
import { Result, StoccaTreError } from "./Result.ts";
import { JSONObject } from "./JSON.ts";
import { StoccaTreDbConn } from "./database.ts";
export default class StoccaTreServer implements StoccaTreRequestHandler {
private db: StoccaTreDbConn;
private routes: {
routeDef: RouteDefinition;
handler: StoccaTreRequestHandler;
}[] = [];
constructor(dbConnection: StoccaTreDbConn) {
this.db = dbConnection;
}
addResource(route: RouteDefinition, handler: StoccaTreRequestHandler) {
this.routes.push({ routeDef: route, handler });
}
async handleRequest(request: StoccaTreRequest): Promise<Result<JSONObject>> {
let result: Result<JSONObject> | null = null;
for (const { routeDef, handler } of this.routes) {
if (request.match(routeDef)) {
result = await handler.handleRequest(request);
break;
}
}
if (!result) {
return [new StoccaTreError(`Invalid route: ${request.route} with method ${request.method}.`, 400)];
}
return result;
}
}

View File

@@ -1,13 +1,15 @@
const config = {
username: Deno.env.get("DB_USER") ?? "postgres",
hostname: Deno.env.get("DB_HOST") ?? "localhost",
password: Deno.env.get("DB_PW") ?? "",
dbUsername: Deno.env.get("DB_USER") ?? "postgres",
dbHostname: Deno.env.get("DB_HOST") ?? "localhost",
dbPassword: Deno.env.get("DB_PASS") ?? "",
dbPort: Number(Deno.env.get("DB_PORT") ?? 5432),
hostname: Deno.env.get("HOST") ?? "localhost",
port: Number(Deno.env.get("PORT") ?? 8080),
};
console.log(`ENV:
db username: ${config.username}
db username: ${config.dbUsername}
db hostname: ${config.dbHostname}
db pass: ******
db port: ${config.dbPort}
server port: ${config.port}

View File

@@ -1,42 +1,73 @@
import { Client } from "postgres";
import config from "./config.ts";
import {Maybe} from "./Maybe.ts";
import {JSONObject} from "./JSON.ts";
import { JSONObject } from "./JSON.ts";
import { Result, StoccaTreError } from "./Result.ts";
type Interpolable = number | string | bigint | null | boolean;
type UnionToIntersection<Union> =
(Union extends any ? (x: Union) => any : never) extends (x: infer Intersection) => any
? Intersection
: never;
type SQLWithArg<Arg> = Arg extends string ? `${string}$${Arg}${string}` : never;
type SQLWithArgs<Args extends Record<string, Interpolable>> = UnionToIntersection<SQLWithArg<keyof Args>>;
type ArgNamesInSQL<SQL extends string> =
SQL extends `${string}$${infer Arg1} ${infer SQLAfterArg1}`
? Arg1 extends `${infer Arg1NoTrailing}${";" | "," | "'" | "\"" | "(" | ")"}`
? Arg1NoTrailing | ArgNamesInSQL<SQLAfterArg1>
: Arg1 | ArgNamesInSQL<SQLAfterArg1>
: SQL extends `${string}$${infer ArgN}`
? ArgN extends `${infer ArgNNoTrailing}${";" | "," | "'" | "\"" | "(" | ")"}`
? ArgNNoTrailing
: ArgN
: never;
type ParametrisedSQL = `${string}$${string}`;
type IsPlainSQL<T> = T extends ParametrisedSQL ? never : T;
type ArgsForSQL<SQL extends string> = SQL extends ParametrisedSQL
? Record<ArgNamesInSQL<SQL>, Interpolable>
: JSONObject | undefined;
// Helper for casting queries
export type Q<T extends JSONObject> = Result<T[]>;
export type WithoutId<T> = Omit<T, "id">;
export interface StoccaTreDbConn {
query<T extends JSONObject | JSONObject[]>(query: string): Promise<Maybe<T>>,
query<Q extends string>(query: Q, ...args: Q extends IsPlainSQL<Q> ? [(JSONObject | undefined)?] : [ArgsForSQL<Q>]): Promise<Result<JSONObject[]>>;
}
export default function createNewDbConnection(): StoccaTreDbConn {
const postgresClient = new Client({
hostname: config.hostname,
password: config.password,
user: config.username,
database: "stocca_tre",
port: config.port,
});
await postgresClient.connect();
return {
async queryMany<T extends JSONObject[]>(query: string): Promise<Maybe<{ rows: T, count: number }>> {
try {
const result = await postgresClient.queryArray<T>(query);
return {
just: {
rows: result.rows,
count: result.rowCount ?? NaN,
},
};
} catch (e: unknown) {
}
},
queryOne<T extends JSONObject>(query: string): Promise<Maybe<T>> {
try {
const result = await postgresClient.queryObject<T>(query);
return { just: result };
}
export default async function createNewDbConnection(): Promise<Result<StoccaTreDbConn>> {
let postgresClient: Client;
try {
postgresClient = new Client({
user: config.dbUsername,
database: "stocca_tre",
hostname: config.hostname,
port: config.dbPort,
password: config.dbPassword,
tls: {
enabled: false,
},
});
await postgresClient.connect();
} catch (e: unknown) {
const error = e as { message?: string };
if (error.message) {
return [new StoccaTreError(error.message).qualified("Error connecting to database: ")];
}
return [new StoccaTreError("Error connecting to database.")];
}
return [,
{
async query<Q extends string>(query: Q, ...args: Q extends IsPlainSQL<Q> ? [(JSONObject | undefined)?] : [ArgsForSQL<Q>]): Promise<Result<JSONObject[]>> {
try {
const result = <{rows: JSONObject[]}> await postgresClient.queryObject(query, args[0]);
return [, result.rows];
} catch (e: unknown) {
const error = e as { message?: string };
return [new StoccaTreError(error.message ?? "Internal database error.")];
}
},
},
];
}

View File

@@ -2,8 +2,9 @@ import createNewDbConnection, { StoccaTreDbConn } from "./database.ts";
import * as resources from "./resources/main.ts";
import config from "./config.ts";
import StoccaTreRequest, { HttpMethod } from "./StoccaTreRequest.ts";
import StoccaTreRequestHandler from "./StoccaTreRequestHandler.ts";
import { JSONObject } from "./JSON.ts";
import StoccaTreServer from "./StoccaTreServer.ts";
import {Result, StoccaTreError} from "./Result.ts";
type StoccaTreApiBody = {
data: JSONObject;
@@ -16,44 +17,68 @@ function newStoccaTreApiBody(): StoccaTreApiBody {
};
}
type StoccaTreServer = StoccaTreRequestHandler & {
dbConnection: StoccaTreDbConn;
};
async function getJSONBody(request: Request): Promise<Result<JSONObject | null>> {
let json;
try {
if (request.body) {
json = await request.json();
} else {
json = null;
}
} catch (e: unknown) {
return [new StoccaTreError((e as Error).message ?? "Body was invalid JSON.", 400)];
}
return [, json];
}
async function processRequest(server: StoccaTreServer, requestEvent: Deno.RequestEvent) {
async function respondWithError(requestEvent: Deno.RequestEvent, error: StoccaTreError) {
const body = newStoccaTreApiBody();
await requestEvent.respondWith(
Response.json({
...body,
status: error.status,
error: error.qualified("Error: ").message,
}, { status: error.status }),
);
}
async function processRequest(
server: StoccaTreServer,
requestEvent: Deno.RequestEvent,
) {
const [bodyError, requestBody] = await getJSONBody(requestEvent.request);
if (bodyError) {
await respondWithError(requestEvent, bodyError);
return;
}
const route = /^https?:\/\/[^\/]*(\/.*)$/.exec(requestEvent.request.url)?.[1] ?? "/";
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(new StoccaTreRequest(method, route, requestBody));
if (result.error) {
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 }));
const [error, result] = await server.handleRequest(new StoccaTreRequest(method, route, requestBody));
if (error) {
await respondWithError(requestEvent, error);
return;
}
body.data = result;
await requestEvent.respondWith(Response.json(body, { status: 200 }));
}
const [error, database] = await createNewDbConnection();
if (error) {
console.log(error.qualified("Failed to create the database: ").message);
} else {
const server = new StoccaTreServer(database);
server.addResource({ pattern: /ingredient/ }, new resources.IngredientResource(database));
server.addResource({ pattern: /user/ }, new resources.UserResource(database));
const stoccaTreListener = Deno.listen({ port: config.port ?? 8080 });
console.log(`Stocca Tre Server is running. Access it at: http://${ config.hostname }:${ config.port }/`);
for await (const conn of stoccaTreListener) {
const httpConn = Deno.serveHttp(conn);
for await (const requestEvent of httpConn) {
await processRequest(server, requestEvent);
}
}
}
const stoccaTreListener = Deno.listen({ port: config.port ?? 8080 });
console.log(`Stocca Tre Server is running. Access it at: http://localhost:${config.port}/`);
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),
};
for await (const conn of stoccaTreListener) {
const httpConn = Deno.serveHttp(conn);
for await (const requestEvent of httpConn) {
await processRequest(stoccaTreServer, requestEvent);
}
}

View File

@@ -1,6 +1,8 @@
import { StoccaTreDbConn, WithoutId } from "../../database.ts";
import { Q, StoccaTreDbConn, WithoutId } from "../../database.ts";
import { IngredientModel } from "./IngredientModel.ts";
import { Maybe } from "../../Maybe.ts";
import { Result, StoccaTreError } from "../../Result.ts";
const TABLE_NAME = "main.ingredients";
export default class IngredientCollection {
private db: StoccaTreDbConn;
@@ -10,36 +12,38 @@ export default class IngredientCollection {
this.db = database;
}
async addIngredient(ingredient: WithoutId<IngredientModel>): Promise<Maybe<IngredientModel[]>> {
return await this.db.query(sql =>
sql<IngredientModel[]>`INSERT INTO ingredients ${ sql(ingredient) }`
);
async addIngredient(ingredient: WithoutId<IngredientModel>): Promise<Result<IngredientModel>> {
const [error, result] = <Q<IngredientModel>> await this.db.query(`INSERT INTO ${ TABLE_NAME } VALUES (DEFAULT, $name, $displayName, $displayNameDE) RETURNING *;`, ingredient);
if (error) {
return [error];
}
if (result.length > 0) {
return [, result[0]];
}
return [new StoccaTreError("Ingredient wasn't inserted!")];
}
async getById(id: number): Promise<Maybe<IngredientModel>> {
async getById(id: number): Promise<Result<IngredientModel>> {
const found = this.mapById.get(id);
if (found) {
return { just: found };
return [, found];
}
const result = await this.db.query(sql => sql<IngredientModel[]>`SELECT * FROM ingredients WHERE id is ${ id }`);
if (result.error) {
return result;
const [error, result] = <Q<IngredientModel>> await this.db.query(`SELECT * FROM ${ TABLE_NAME } WHERE id is $id`, { id });
if (error) {
return [error];
}
const ingredient = result.just[0];
const ingredient = result[0];
this.mapById.set(ingredient.id, ingredient);
return { just: ingredient };
return [, ingredient];
}
async getAllIngredients(): Promise<Maybe<IterableIterator<IngredientModel>>> {
const result: Maybe<IngredientModel[]> = await this.db.query(sql =>
sql<IngredientModel[]>`SELECT * FROM ingredients`
);
if (!result.error) {
result.just.forEach(ingredient => this.mapById.set(ingredient.id, ingredient));
async getAllIngredients(): Promise<Result<IterableIterator<IngredientModel>>> {
const [error, result] = <Q<IngredientModel>> await this.db.query(`SELECT * FROM ${ TABLE_NAME }`);
if (!error) {
result.forEach((ingredient: IngredientModel) => this.mapById.set(ingredient.id, ingredient));
} else {
return result;
return [error];
}
return {
just: this.mapById.values(),
};
return [, this.mapById.values()];
}
}

View File

@@ -9,4 +9,6 @@ export const IngredientSchema = z.object({
export const IngredientSchemaWithoutId = IngredientSchema.omit({ id: true });
export type IngredientModel = z.infer<typeof IngredientSchema>;
export type IngredientModel = z.infer<typeof IngredientSchema>;

View File

@@ -1,9 +1,9 @@
import {StoccaTreDbConn} from "../../database.ts";
import { StoccaTreDbConn } from "../../database.ts";
import IngredientCollection from "./IngredientCollection.ts";
import StoccaTreRequest, {RouteDefinition} from "../../StoccaTreRequest.ts";
import {Maybe} from "../../Maybe.ts";
import {JSONObject} from "../../JSON.ts";
import {IngredientSchemaWithoutId} from "./IngredientModel.ts";
import StoccaTreRequest, { RouteDefinition } from "../../StoccaTreRequest.ts";
import {Result, StoccaTreError} from "../../Result.ts";
import { JSONObject } from "../../JSON.ts";
import { IngredientModel, IngredientSchemaWithoutId } from "./IngredientModel.ts";
export default class IngredientResource {
private dbConnection: StoccaTreDbConn;
@@ -11,11 +11,11 @@ export default class IngredientResource {
private routes: Readonly<Record<string, RouteDefinition>> = {
Add: {
pattern: /\/add/,
method: "POST"
method: "POST",
},
GetAll: {
pattern: /\/all/,
method: "GET"
method: "GET",
},
} as const;
@@ -24,33 +24,40 @@ export default class IngredientResource {
this.collection = new IngredientCollection(dbConnection);
}
async handleRequest(request: StoccaTreRequest): Promise<Maybe<JSONObject>> {
async handleRequest(request: StoccaTreRequest): Promise<Result<JSONObject> | null> {
let result;
if (request.match(this.routes.Add)) {
return await this.addIngredient(request);
result = await this.addIngredient(request);
}
if (request.match(this.routes.GetAll)) {
return await this.allIngredients(request);
result = await this.allIngredients(request);
}
return { error: { message: "Invalid route" }};
if (result) {
const [error] = result;
if (error) {
return [error.qualified("Could not fulfill ingredient request: ")];
} else {
return result;
}
}
return null;
}
private async addIngredient(request: StoccaTreRequest): Promise<Maybe<{ insertedId: number }>> {
const ingredient = IngredientSchemaWithoutId.safeParse(JSON.parse(request.body ?? "{}"));
private async addIngredient(request: StoccaTreRequest): Promise<Result<{ id: number }>> {
const ingredient = IngredientSchemaWithoutId.safeParse(request.body);
if (!ingredient.success) {
return { error: new Error("Ingredient was malformed.") };
return [new StoccaTreError("Ingredient definition was malformed.", 400)];
}
return await this.collection.addIngredient(ingredient.data);
}
private async allIngredients(request: StoccaTreRequest): Promise<Maybe<JSONObject>> {
const getAllIngredientResult = await this.collection.getAllIngredients();
if (getAllIngredientResult.error) {
return getAllIngredientResult;
private async allIngredients(request: StoccaTreRequest): Promise<Result<JSONObject>> {
const [error, ingredients] = await this.collection.getAllIngredients();
if (error) {
return [error];
}
return {
just: {
ingredients: Array.from(getAllIngredientResult.just),
},
};
return [, {
ingredients: Array.from(ingredients),
}];
}
}
}

View File

@@ -1,7 +1,9 @@
import * as bcrypt from "bcrypt";
import { StoccaTreDbConn, WithoutId } from "../../database.ts";
import {Q, StoccaTreDbConn, WithoutId} from "../../database.ts";
import { UserModel } from "./UserModel.ts";
import { Maybe } from "../../Maybe.ts";
import {Result, StoccaTreError} from "../../Result.ts";
const TABLE_NAME = "main.users";
export default class UserCollection {
private db: StoccaTreDbConn;
@@ -11,40 +13,34 @@ export default class UserCollection {
this.db = database;
}
async addUser(user: WithoutId<UserModel>): Promise<Maybe<{ insertedId: number }>> {
async addUser(user: WithoutId<UserModel>): Promise<Result<{ id: number }>> {
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 [new StoccaTreError(error.message)];
}
return { error: new Error("Failed to create user") };
return [new StoccaTreError("Failed to create user")];
}
user.password = hash;
const result: Maybe<UserModel[]> = await this.db.query(sql => sql`
INSERT INTO users ${ sql(user, "displayName") }
RETURNING *
`);
if (result.error) {
return result;
const [error, users] = <Q<UserModel>> await this.db.query(`INSERT INTO ${TABLE_NAME} ($displayName, $password) RETURNING *`, user);
if (error) {
return [error];
}
return {
just: { insertedId: result.just[0]?.id ?? NaN }
if (users.length === 0) {
return [new StoccaTreError("Failed to insert user.")];
}
return [, users[0]];
}
async getAllUsers(): Promise<Maybe<UserModel[]>> {
const result = await this.db.query((sql) => sql<UserModel[]>`SELECT * FROM users`);
if (!result.error) {
result.just.forEach(user => this.mapById.set(user.id, user));
} else {
return result;
async getAllUsers(): Promise<Result<UserModel[]>> {
const [error, users] = <Q<UserModel>> await this.db.query(`SELECT * FROM ${TABLE_NAME}`);
if (error) {
return [error];
}
return {
just: Array.from(this.mapById.values()),
};
users.forEach((user) => this.mapById.set(user.id, user));
return [, Array.from(this.mapById.values())];
}
}

View File

@@ -9,4 +9,4 @@ export const UserSchema = z.object({
export const UserSchemaWithoutId = UserSchema.omit({ id: true });
export type UserModel = z.infer<typeof UserSchema>;
export type UserModel = z.infer<typeof UserSchema>;

View File

@@ -1,9 +1,9 @@
import {StoccaTreDbConn} from "../../database.ts";
import StoccaTreRequest, {RouteDefinition} from "../../StoccaTreRequest.ts";
import {Maybe} from "../../Maybe.ts";
import {JSONObject} from "../../JSON.ts";
import { StoccaTreDbConn } from "../../database.ts";
import StoccaTreRequest, { RouteDefinition } from "../../StoccaTreRequest.ts";
import {Result, StoccaTreError} from "../../Result.ts";
import { JSONObject } from "../../JSON.ts";
import UserCollection from "./UserCollection.ts";
import {UserSchemaWithoutId} from "./UserModel.ts";
import { UserSchemaWithoutId } from "./UserModel.ts";
export default class UserResource {
private dbConnection: StoccaTreDbConn;
@@ -11,11 +11,11 @@ export default class UserResource {
private routes: Readonly<Record<string, RouteDefinition>> = {
Add: {
pattern: /\/add/,
method: "POST"
method: "POST",
},
GetAll: {
pattern: /\/all/,
method: "GET"
method: "GET",
},
} as const;
@@ -24,33 +24,29 @@ export default class UserResource {
this.collection = new UserCollection(dbConnection);
}
async handleRequest(request: StoccaTreRequest): Promise<Maybe<JSONObject>> {
async handleRequest(request: StoccaTreRequest): Promise<Result<JSONObject> | null> {
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" }};
return null;
}
private async addUser(request: StoccaTreRequest): Promise<Maybe<{ insertedId: number }>> {
const user = UserSchemaWithoutId.safeParse(JSON.parse(request.body ?? "{}"));
private async addUser(request: StoccaTreRequest): Promise<Result<{ id: number }>> {
const user = UserSchemaWithoutId.safeParse(request.body);
if (!user.success) {
return { error: new Error("Ingredient was malformed.") };
return [new StoccaTreError("User definition was malformed.", 400)];
}
return await this.collection.addUser(user.data);
}
private async allUsers(request: StoccaTreRequest): Promise<Maybe<JSONObject>> {
const allUsers = await this.collection.getAllUsers();
if (allUsers.error) {
return allUsers;
private async allUsers(request: StoccaTreRequest): Promise<Result<JSONObject>> {
const [error, result] = await this.collection.getAllUsers();
if (error) {
return [error];
}
return {
just: {
ingredients: Array.from(allUsers.just),
},
};
return [, { users: result }];
}
}

View File

@@ -1,3 +1,3 @@
export * from "./UserModel.ts";
export * from "./UserCollection.ts";
export * from "./UserResource.ts";
export { default as UserCollection } from "./UserCollection.ts";
export { default as UserResource } from "./UserResource.ts";

View File

@@ -1 +1 @@
DB_USER=postgres && DB_PASS=postgres && deno run --allow-env --allow-net --import-map=import_map.json main.ts
DB_USER=postgres DB_PASS=postgres deno run --allow-read --allow-env --allow-net --import-map=import_map.json main.ts