Skip to content

Commit

Permalink
session basics
Browse files Browse the repository at this point in the history
  • Loading branch information
evantahler committed Mar 30, 2024
1 parent 7bc1218 commit 63ce72e
Show file tree
Hide file tree
Showing 10 changed files with 180 additions and 52 deletions.
10 changes: 1 addition & 9 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,6 @@ jobs:
- run: bun install
- run: bun run prettier --check .

docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- run: bun install
- run: bun run typedoc api.ts

test:
runs-on: ubuntu-latest

Expand Down Expand Up @@ -60,6 +52,6 @@ jobs:

complete:
runs-on: ubuntu-latest
needs: [build, lint, docs, test]
needs: [build, lint, test]
steps:
- run: echo "Done!"
66 changes: 66 additions & 0 deletions __tests__/actions/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { test, expect, beforeAll, afterAll } from "bun:test";
import { api, type ActionResponse } from "../../api";
import { config } from "../../config";
import { users } from "../../schema/users";
import { hashPassword } from "../../ops/UserOps";
import type { SessionCreate } from "../../actions/session";

const url = `http://${config.server.web.host}:${config.server.web.port}`;

beforeAll(async () => {
await api.start();
await api.db.clearDatabase();
await api.db.db.insert(users).values({
name: "Mario Mario",
email: "[email protected]",
password_hash: await hashPassword("mushroom1"),
});
});

afterAll(async () => {
await api.stop();
});

test("returns user when matched", async () => {
const res = await fetch(url + "/api/session", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "[email protected]",
password: "mushroom1",
}),
});
const response = (await res.json()) as ActionResponse<SessionCreate>;
expect(res.status).toBe(200);

expect(response.id).toEqual(1);
expect(response.name).toEqual("Mario Mario");
});

test("fails when users is not found", async () => {
const res = await fetch(url + "/api/session", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "[email protected]",
password: "xxx",
}),
});
const response = (await res.json()) as ActionResponse<SessionCreate>;
expect(res.status).toBe(500);
expect(response.error?.message).toEqual("User not found");
});

test("fails when passwords do not match", async () => {
const res = await fetch(url + "/api/session", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: "[email protected]",
password: "yoshi",
}),
});
const response = (await res.json()) as ActionResponse<SessionCreate>;
expect(res.status).toBe(500);
expect(response.error?.message).toEqual("Password does not match");
});
4 changes: 2 additions & 2 deletions actions/hello.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { Action, type ActionParams } from "../api";
import { HTTP_METHOD } from "../classes/Action";
import { ensureString } from "../util/formatters";
import { nameValidator } from "../util/validators";

export class Hello implements Action {
name = "hello";
web = { route: "/hello", method: HTTP_METHOD.POST };
inputs = {
name: {
required: true,
validator: (p: string) =>
p.length <= 0 ? "Name must be at least 1 character" : true,
validator: nameValidator,
formatter: ensureString,
},
};
Expand Down
46 changes: 46 additions & 0 deletions actions/session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { eq } from "drizzle-orm";
import { api, type Action, type ActionParams } from "../api";
import { users } from "../schema/users";
import { ensureString } from "../util/formatters";
import { emailValidator, passwordValidator } from "../util/validators";
import { serializeUser, checkPassword } from "../ops/UserOps";
import { ErrorType, TypedError } from "../classes/TypedError";
import { HTTP_METHOD } from "../classes/Action";

export class SessionCreate implements Action {
name = "sessionCreate";
web = { route: "/session", method: HTTP_METHOD.PUT };
inputs = {
email: {
required: true,
validator: emailValidator,
formatter: ensureString,
},
password: {
required: true,
validator: passwordValidator,
formatter: ensureString,
},
};

run = async (params: ActionParams<SessionCreate>) => {
const [user] = await api.db.db
.select()
.from(users)
.where(eq(users.email, params.email.toLowerCase()));

if (!user) {
throw new TypedError("User not found", ErrorType.CONNECTION_ACTION_RUN);
}

const passwordMatch = await checkPassword(user, params.password);
if (!passwordMatch) {
throw new TypedError(
"Password does not match",
ErrorType.CONNECTION_ACTION_RUN,
);
}

return serializeUser(user);
};
}
32 changes: 16 additions & 16 deletions actions/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,42 +3,42 @@ import { HTTP_METHOD } from "../classes/Action";
import { hashPassword, serializeUser } from "../ops/UserOps";
import { users } from "../schema/users";
import { ensureString } from "../util/formatters";
import {
emailValidator,
nameValidator,
passwordValidator,
} from "../util/validators";

export class UserCreate implements Action {
name = "userCreate";
web = { route: "/user", method: HTTP_METHOD.PUT };
inputs = {
name: {
required: true,
validator: (p: string) =>
p.length < 3 ? "Name must be at least 3 characters" : true,
validator: nameValidator,
formatter: ensureString,
},
email: {
required: true,
validator: (p: string) =>
p.length < 3 || !p.includes("@") ? "Email invalid" : true,
validator: emailValidator,
formatter: ensureString,
},
password: {
required: true,
validator: (p: string) =>
p.length < 3 ? "Password must be at least 3 characters" : true,
validator: passwordValidator,
formatter: ensureString,
},
};

async run(params: ActionParams<UserCreate>) {
const user = (
await api.db.db
.insert(users)
.values({
name: params.name,
email: params.email,
password_hash: await hashPassword(params.password),
})
.returning()
)[0];
const [user] = await api.db.db
.insert(users)
.values({
name: params.name,
email: params.email.toLowerCase(),
password_hash: await hashPassword(params.password),
})
.returning();

return serializeUser(user);
}
Expand Down
7 changes: 3 additions & 4 deletions classes/Action.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Inputs } from "./Inputs";
import type { Connection } from "./Connection";
import type { Input } from "./Input";
import type { TypedError } from "./TypedError";

export enum HTTP_METHOD {
"GET" = "GET",
Expand All @@ -25,7 +26,7 @@ export abstract class Action {
name: string;
description?: string;
inputs: Inputs;
web: {
web?: {
route: RegExp | string;
method: HTTP_METHOD;
};
Expand Down Expand Up @@ -62,6 +63,4 @@ type TypeFromFormatterOrUnknown<I extends Input> = I["formatter"] extends (
: unknown;

export type ActionResponse<A extends Action> = Awaited<ReturnType<A["run"]>> &
Partial<{
error?: { message: string; stack?: string };
}>;
Partial<{ error?: TypedError }>;
30 changes: 15 additions & 15 deletions classes/TypedError.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
export enum ErrorType {
// general
"SERVER_INITIALIZATION",
"SERVER_START",
"SERVER_STOP",
"SERVER_INITIALIZATION" = "SERVER_INITIALIZATION",
"SERVER_START" = "SERVER_START",
"SERVER_STOP" = "SERVER_STOP",

// init
"CONFIG_ERROR",
"INITIALIZER_VALIDATION",
"ACTION_VALIDATION",
"TASK_VALIDATION",
"SERVER_VALIDATION",
"CONFIG_ERROR" = "CONFIG_ERROR",
"INITIALIZER_VALIDATION" = "INITIALIZER_VALIDATION",
"ACTION_VALIDATION" = "ACTION_VALIDATION",
"TASK_VALIDATION" = "TASK_VALIDATION",
"SERVER_VALIDATION" = "SERVER_VALIDATION",

// actions
"CONNECTION_SERVER_ERROR",
"CONNECTION_ACTION_NOT_FOUND",
"CONNECTION_ACTION_PARAM_REQUIRED",
"CONNECTION_ACTION_PARAM_DEFAULT",
"CONNECTION_ACTION_PARAM_VALIDATION",
"CONNECTION_ACTION_PARAM_FORMATTING",
"CONNECTION_ACTION_RUN",
"CONNECTION_SERVER_ERROR" = "CONNECTION_SERVER_ERROR",
"CONNECTION_ACTION_NOT_FOUND" = "CONNECTION_ACTION_NOT_FOUND",
"CONNECTION_ACTION_PARAM_REQUIRED" = "CONNECTION_ACTION_PARAM_REQUIRED",
"CONNECTION_ACTION_PARAM_DEFAULT" = "CONNECTION_ACTION_PARAM_DEFAULT",
"CONNECTION_ACTION_PARAM_VALIDATION" = "CONNECTION_ACTION_PARAM_VALIDATION",
"CONNECTION_ACTION_PARAM_FORMATTING" = "CONNECTION_ACTION_PARAM_FORMATTING",
"CONNECTION_ACTION_RUN" = "CONNECTION_ACTION_RUN",
}

export class TypedError extends Error {
Expand Down
6 changes: 2 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,7 @@
"@types/pg": "^8.11.4",
"@types/react-dom": "^18.2.22",
"drizzle-kit": "^0.20.14",
"prettier": "^3.2.5",
"typedoc": "^0.25.12"
"prettier": "^3.2.5"
},
"peerDependencies": {
"typescript": "^5.0.0"
Expand All @@ -27,7 +26,6 @@
"migrations": "bun run migrations.ts",
"lint": "prettier --check .",
"pretty": "prettier --write .",
"ci": "bun run type_doc && bun run lint && bun test",
"type_doc": "bun run typedoc api.ts"
"ci": "bun run lint && bun test"
}
}
4 changes: 2 additions & 2 deletions servers/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {

async start() {
logger.info(
`starting web server @ ${config.server.web.host}:${config.server.web.port}`,
`starting web server @ http://${config.server.web.host}:${config.server.web.port}`,
);

this.server = Bun.serve({
Expand Down Expand Up @@ -193,7 +193,7 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
);

for (const action of api.actions.actions) {
if (!action.web.route) continue;
if (!action?.web?.route) continue;

const matcher =
action.web.route instanceof RegExp
Expand Down
27 changes: 27 additions & 0 deletions util/validators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const stringLengthValidator = (s: string, minLength: number) => {
if (s.length < minLength) {
throw new Error(
`This field is required and must be at least ${minLength} characters long`,
);
}
};

export const nameValidator = (p: string) => {
stringLengthValidator(p, 3);

return true as const;
};

export const emailValidator = (p: string): true => {
stringLengthValidator(p, 3);
if (!p.includes("@")) throw new Error(`This is not a valid email`);
if (!p.includes(".")) throw new Error(`This is not a valid email`);

return true as const;
};

export const passwordValidator = (p: string): true => {
stringLengthValidator(p, 3);

return true as const;
};

0 comments on commit 63ce72e

Please sign in to comment.