Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ORM, types, and more! #3

Merged
merged 18 commits into from
Mar 25, 2024
9 changes: 7 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,10 @@ servers.web.host=0.0.0.0
servers.web.apiRoute="/api"
servers.web.assetRoute="/assets"
servers.web.pageRoute="/pages"
servers.web.closeActiveConnectionsOnStop=false
servers.web.closeActiveConnectionsOnStop.test=true

db.connectionString="postgres://postgres:postgres@localhost:5432/bun"
db.connectionString.test="postgres://postgres:postgres@localhost:5432/bun-test"
db.autoMigrate=true

redis.connectionString="redis://localhost:6379/0"
redis.connectionString.test="redis://localhost:6379/1"
17 changes: 17 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,23 @@ jobs:

test:
runs-on: ubuntu-latest

services:
redis:
image: redis
postgres:
image: postgres
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: bun-test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432

steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ bun run prettier --write .

This project was created using `bun init` in bun v1.0.29. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

## Databases and Migrations

This project uses Drizzle as the ORM. Migrations are derived from the schemas. To create a migration from changes in `scheams/*.ts` run `bun run migrations.ts`. Then, restart the server - pending migrations are auto-applied.

## Intentional changes from ActionHero

**Process**
Expand Down Expand Up @@ -90,3 +94,7 @@ This project was created using `bun init` in bun v1.0.29. [Bun](https://bun.sh)
**Testing**

- No mock server. Let's make real API requests. Now that bun has `fetch` included, it's easy.

**ORM**

- we use drizzle for the ORM and migrations.
47 changes: 47 additions & 0 deletions __tests__/actions/user.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { test, expect, beforeAll, afterAll } from "bun:test";
import { api, type ActionResponse } from "../../api";
import type { UserCreate } from "../../actions/user";
import { config } from "../../config";

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

beforeAll(async () => {
await api.start();
await api.db.clearDatabase();
});

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

test("user can be created", async () => {
const res = await fetch(url + "/api/user", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "person 1",
email: "[email protected]",
password: "password",
}),
});
const response = (await res.json()) as ActionResponse<UserCreate>;
expect(res.status).toBe(200);

expect(response.id).toEqual(1);
expect(response.email).toEqual("[email protected]");
});

test("email must be unique", async () => {
const res = await fetch(url + "/api/user", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "person 1",
email: "[email protected]",
password: "password",
}),
});
const response = (await res.json()) as ActionResponse<UserCreate>;
expect(res.status).toBe(500);
expect(response.error?.message).toMatch(/violates unique constraint/);
});
26 changes: 12 additions & 14 deletions actions/hello.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
import { Action, type ActionParams } from "../api";
import { HTTP_METHOD } from "../classes/Action";
import { ensureString } from "../util/formatters";

export class Hello extends Action {
constructor() {
super({
name: "hello",
web: { route: "/hello", method: "POST" },
inputs: {
name: {
required: true,
validator: (p) => p.length > 0,
formatter: ensureString,
},
},
});
}
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" : undefined,
formatter: ensureString,
},
};

async run(params: ActionParams<Hello>) {
return { message: `Hello, ${params.name}!` };
Expand Down
14 changes: 6 additions & 8 deletions actions/status.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { api, Action } from "../api";
import { api, Action, type Inputs } from "../api";
import { HTTP_METHOD } from "../classes/Action";
import packageJSON from "../package.json";

export class Status extends Action {
constructor() {
super({
name: "status",
web: { route: "/status", method: "GET" },
});
}
export class Status implements Action {
name = "status";
inputs = {};
web = { route: "/status", method: HTTP_METHOD.GET };

async run() {
const consumedMemoryMB =
Expand Down
14 changes: 6 additions & 8 deletions actions/swagger.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Action, config, api } from "../api";
import { HTTP_METHOD } from "../classes/Action";
import packageJSON from "../package.json";

const SWAGGER_VERSION = "2.0";
Expand Down Expand Up @@ -28,14 +29,11 @@ type SwaggerPath = {
};
};

export class Swagger extends Action {
constructor() {
super({
name: "swagger",
description: "return API documentation in the OpenAPI specification",
web: { route: "/swagger", method: "GET" },
});
}
export class Swagger implements Action {
name = "swagger";
description = "return API documentation in the OpenAPI specification";
inputs = {};
web = { route: "/swagger", method: HTTP_METHOD.GET };

async run() {
const swaggerPaths = buildSwaggerPaths();
Expand Down
45 changes: 45 additions & 0 deletions actions/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { api, Action, type ActionParams } from "../api";
import { HTTP_METHOD } from "../classes/Action";
import { hashPassword, serializeUser } from "../ops/UserOps";
import { users } from "../schema/users";
import { ensureString } from "../util/formatters";

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" : undefined,
formatter: ensureString,
},
email: {
required: true,
validator: (p: string) =>
p.length < 3 || !p.includes("@") ? "Email invalid" : undefined,
formatter: ensureString,
},
password: {
required: true,
validator: (p: string) =>
p.length < 3 ? "Password must be at least 3 characters" : undefined,
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];

return serializeUser(user);
}
}
Binary file modified bun.lockb
Binary file not shown.
34 changes: 23 additions & 11 deletions classes/API.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { config } from "../config";
import { globLoader } from "../util/glob";
import type { Initializer, InitializerSortKeys } from "./Initializer";
import { Logger } from "./Logger";
import { ErrorType, TypedError } from "./TypedError";

export class API {
rootDir: string;
Expand Down Expand Up @@ -36,11 +37,14 @@ export class API {
this.sortInitializers("loadPriority");

for (const initializer of this.initializers) {
this.logger.debug(`Initializing initializer ${initializer.name}`);
await initializer.validate();
const response = await initializer.initialize?.();
if (response) this[initializer.name] = response;
this.logger.debug(`Initialized initializer ${initializer.name}`);
try {
this.logger.debug(`Initializing initializer ${initializer.name}`);
const response = await initializer.initialize?.();
if (response) this[initializer.name] = response;
this.logger.debug(`Initialized initializer ${initializer.name}`);
} catch (e) {
throw new TypedError(`${e}`, ErrorType.SERVER_INITIALIZATION);
}
}

this.initialized = true;
Expand All @@ -57,9 +61,13 @@ export class API {
this.sortInitializers("startPriority");

for (const initializer of this.initializers) {
this.logger.debug(`Starting initializer ${initializer.name}`);
const response = await initializer.start?.();
this.logger.debug(`Started initializer ${initializer.name}`);
try {
this.logger.debug(`Starting initializer ${initializer.name}`);
await initializer.start?.();
this.logger.debug(`Started initializer ${initializer.name}`);
} catch (e) {
throw new TypedError(`${e}`, ErrorType.SERVER_START);
}
}

this.started = true;
Expand All @@ -74,9 +82,13 @@ export class API {
this.sortInitializers("stopPriority");

for (const initializer of this.initializers) {
this.logger.debug(`Stopping initializer ${initializer.name}`);
await initializer.stop?.();
this.logger.debug(`Stopped initializer ${initializer.name}`);
try {
this.logger.debug(`Stopping initializer ${initializer.name}`);
await initializer.stop?.();
this.logger.debug(`Stopped initializer ${initializer.name}`);
} catch (e) {
throw new TypedError(`${e}`, ErrorType.SERVER_STOP);
}
}

this.stopped = true;
Expand Down
26 changes: 10 additions & 16 deletions classes/Action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@ import type { Inputs } from "./Inputs";
import type { Connection } from "./Connection";
import type { Input } from "./Input";

export const httpMethods = [
"GET",
"POST",
"PUT",
"DELETE",
"PATCH",
"OPTIONS",
] as const;
export type HTTP_METHOD = (typeof httpMethods)[number];
export enum HTTP_METHOD {
"GET" = "GET",
"POST" = "POST",
"PUT" = "PUT",
"DELETE" = "DELETE",
"PATCH" = "PATCH",
"OPTIONS" = "OPTIONS",
}

export type ActionConstructorInputs = {
name: string;
Expand All @@ -24,7 +23,7 @@ export type ActionConstructorInputs = {

export abstract class Action {
name: string;
description: string;
description?: string;
inputs: Inputs;
web: {
route: RegExp | string;
Expand All @@ -37,7 +36,7 @@ export abstract class Action {
this.inputs = args.inputs ?? ({} as Inputs);
this.web = {
route: args.web?.route ?? `/${this.name}`,
method: args.web?.method ?? "GET",
method: args.web?.method ?? HTTP_METHOD.GET,
};
}

Expand All @@ -51,11 +50,6 @@ export abstract class Action {
params: ActionParams<typeof this>,
connection: Connection, // ): ActionResponse<typeof this>;
): Promise<any>;

async validate() {
if (!this.name) throw new Error("Action name is required");
if (!this.description) throw new Error("Action description is required");
}
}

export type ActionParams<A extends Action> = {
Expand Down
Loading