basic api

This commit is contained in:
tsukino
2024-01-02 16:46:16 -08:00
parent 94fcc09440
commit 5fea0f459c
4 changed files with 168 additions and 35 deletions

View File

@@ -1,6 +1,6 @@
import IHttpServer, {
HttpServerOptions,
StatusCode,
parseJsonBody,
} from './services/web/index.ts';
import IDatabase, { DatabaseOptions } from './services/db/index.ts';
@@ -30,24 +30,36 @@ export default class GroupRegistry {
}
}
this.#http!.on('/health', (_, res) => {
res.writeHead(StatusCode.Ok, { 'Content-Type': 'text' });
res.end('ok');
this.#http!.get('/health', (_, res) => {
res.send('ok');
});
this.#http!.on('/groups/:id', async (req, res) => {
this.#http!.get('/groups/:id/members', async (req, res) => {
res.send(await this.#db?.getGroupMembers(req.params!.id));
});
this.#http!.get('/groups/:id', async (req, res) => {
const { id } = req.params!;
const result = await this.#db?.getGroupInfo(id);
res.writeHead(StatusCode.Ok, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
res.send(result);
});
this.#http!.on('/groups', async (req, res) => {
console.time('groups');
this.#http!.get('/groups', async (req, res) => {
const result = await this.#db?.getGroups();
res.writeHead(StatusCode.Ok, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
console.timeEnd('groups');
res.send(result);
});
this.#http!.get('/commitments/:commitment', async (req, res) => {
const { commitment } = req.params!;
const result = await this.#db?.getGroupsByCommitment(commitment);
res.send(result);
});
this.#http!.post('/groups/:id/members', parseJsonBody, async (req, res) => {
const { id } = req.params!;
const { commitment } = req.body!;
await this.#db!.insertCommitment(commitment, id);
res.send('ok');
});
}
}

View File

@@ -32,19 +32,22 @@ export default class Database {
async #prepareTables() {
await this.#pool.query(`
CREATE TABLE IF NOT EXISTS users (
commitment VARCHAR(66) PRIMARY KEY,
group_id VARCHAR(255) NOT NULL
);
CREATE INDEX IF NOT EXISTS users_group_idx ON users (group_id);
CREATE TABLE IF NOT EXISTS groups (
group_id VARCHAR(255) PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description VARCHAR(4095) NOT NULL,
icon_url VARCHAR(255)
);
CREATE TABLE IF NOT EXISTS commitments (
commitment VARCHAR(66) NOT NULL,
group_id VARCHAR(255) REFERENCES groups(group_id),
UNIQUE(commitment, group_id)
);
CREATE INDEX IF NOT EXISTS commitments_gp_idx ON commitments (group_id);
CREATE INDEX IF NOT EXISTS commitments_cmt_idx ON commitments (commitment);
`);
for (const group of groups) {
@@ -65,7 +68,7 @@ export default class Database {
async insertCommitment(commitment: string, groupId: string) {
await this.#pool.query(
`
INSERT INTO users(commitment, group_id)
INSERT INTO commitments(commitment, group_id)
VALUES ($1, $2)
`,
[commitment, groupId],
@@ -79,7 +82,7 @@ export default class Database {
) {
await this.#pool.query(
`
UPDATE users(commitment, group_id)
UPDATE commitments(commitment, group_id)
SET commitment = $1
WHERE group_id = $2 AND commitment = $3
`,
@@ -90,7 +93,7 @@ export default class Database {
async getGroupInfo(groupId: string): Promise<GroupInfo | null> {
const res = await this.#pool.query(
`
SELECT * FROM groups
SELECT g.group_id, g.title, g.description, g.icon_url FROM groups g
WHERE group_id = $1
`,
[groupId],
@@ -102,11 +105,37 @@ export default class Database {
async getGroups(): Promise<GroupInfo[]> {
const res = await this.#pool.query(
`
SELECT * FROM groups
SELECT g.group_id, g.title, g.description, g.icon_url FROM groups g
`,
[],
);
return res.rows;
}
async getGroupMembers(groupId: string): Promise<GroupInfo[]> {
const res = await this.#pool.query(
`
SELECT commitment FROM commitments
WHERE group_id = $1
`,
[groupId],
);
return res.rows.map(({ commitment }) => commitment);
}
async getGroupsByCommitment(commitment: string): Promise<GroupInfo[]> {
const res = await this.#pool.query(
`
SELECT g.group_id, g.title, g.description, g.icon_url FROM commitments
LEFT OUTER JOIN groups g
ON g.group_id = commitments.group_id
WHERE commitment = $1
`,
[commitment],
);
return res.rows;
}
}

View File

@@ -20,13 +20,37 @@ export enum StatusCode {
ServiceUnavailable = 503,
}
export enum HttpMethod {
GET = 'GET',
POST = 'POST',
UPDATE = 'UPDATE',
DELETE = 'DELETE',
}
export type Request = http.IncomingMessage & {
params?: { [key: string]: string };
body?: any;
};
export type Response = http.ServerResponse & {
send(data?: any): void;
};
export type Next = (request?: Request) => void;
export type RouteHandler = (
request: Request,
response: Response,
next: Next,
) => Promise<void> | void;
export default class HttpServer {
#port: number;
#handlers: [string, (req: Request, res: http.ServerResponse) => void][] = [];
#handlers: [
HttpMethod,
string, // pattern
RouteHandler[],
][] = [];
#server: http.Server;
constructor(options?: HttpServerOptions) {
@@ -45,14 +69,36 @@ export default class HttpServer {
});
}
#onRequest = (req: Request, res: http.ServerResponse) => {
#onRequest = async (req: http.IncomingMessage, res: http.ServerResponse) => {
const url = parse(req.url || '', true);
for (const [pattern, handler] of this.#handlers) {
for (const [method, pattern, handlers] of this.#handlers) {
const params = testPath(pattern, url.pathname);
if (params) {
req.params = params;
handler(req, res);
if (params && req.method === method) {
let request = requestify(req, params);
const response = responsify(res);
for (const handler of handlers) {
request = await new Promise(async (resolve) => {
try {
await handler(
request,
response,
// @ts-ignore
resolve,
);
} catch (e) {
response.writeHead(StatusCode.InternalServerError, {
'Content-Type': 'application/json',
});
response.send(e.message);
logger.error(e);
return;
}
});
}
return;
}
}
@@ -61,10 +107,55 @@ export default class HttpServer {
res.end('not implememted');
};
on = (
path: string,
handler: (req: Request, res: http.ServerResponse) => void,
) => {
this.#handlers.push([path, handler]);
get = (path: string, ...handler: RouteHandler[]) => {
this.#handlers.push([HttpMethod.GET, path, handler]);
};
post = (path: string, ...handler: RouteHandler[]) => {
this.#handlers.push([HttpMethod.POST, path, handler]);
};
}
function responsify(res: http.ServerResponse): Response {
// @ts-ignore
res.send = (data?: any) => {
if (!data) {
res.writeHead(StatusCode.Ok, { 'Content-Type': 'text' });
res.end('');
} else if (typeof data === 'string') {
res.writeHead(StatusCode.Ok, { 'Content-Type': 'text' });
res.end(data);
} else {
res.writeHead(StatusCode.Ok, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
};
return res as Response;
}
function requestify(
req: http.IncomingMessage,
params?: { [key: string]: string },
): Request {
// @ts-ignore
req.params = params;
return req as Request;
}
export function parseJsonBody(req: Request, res: Response, next: Next) {
const requestBody: any[] = [];
req.on('data', (chunks) => {
requestBody.push(chunks);
});
req.on('end', () => {
try {
const parsedData = Buffer.concat(requestBody).toString();
req.body = JSON.parse(parsedData);
next(req);
} catch (e) {}
});
req.on('error', () => {});
}

View File

@@ -1,7 +1,8 @@
import * as console from 'console';
import * as console from "console";
const logger = {
info: console.log.bind(console),
error: console.error.bind(console),
verbose: (...args: any[]) => {
if (process.env.VERBOSE) {
console.log(...args);