diff --git a/bun.lockb b/bun.lockb index a2c7d92..95721aa 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/db/schema.sql b/db/schema.sql index 662cdd8..481e78a 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,14 +1,14 @@ CREATE TABLE authors ( - id SERIAL PRIMARY KEY, - author_id uuid DEFAULT gen_random_uuid(), - name VARCHAR(100) NOT NULL, - bio TEXT + id SERIAL PRIMARY KEY, + uuid uuid DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL, + bio TEXT ); CREATE TABLE books ( - book_id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + uuid uuid DEFAULT gen_random_uuid() PRIMARY KEY, title VARCHAR(255) NOT NULL, author_id INT NOT NULL, published_date DATE, diff --git a/package.json b/package.json index 2fcbd5c..b887f80 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "pg": "^8.11.3" }, "devDependencies": { - "@types/bun": "^1.0.4", "@types/pg": "^8.10.9", + "bun-types": "^1.0.25", "prettier": "^3.2.4" }, "peerDependencies": { diff --git a/src/db-types.ts b/src/db-types.ts new file mode 100644 index 0000000..4aba7b9 --- /dev/null +++ b/src/db-types.ts @@ -0,0 +1,14 @@ +export type DbBook = { + uuid: string + title: string + published_date: Date + isbn: string + author_id: number +} + +export type DbAuthor = { + id: number + uuid: string + name: string + bio: string +} diff --git a/src/server.test.ts b/src/server.test.ts new file mode 100644 index 0000000..be5dbac --- /dev/null +++ b/src/server.test.ts @@ -0,0 +1,117 @@ +import { test, describe, expect, beforeAll, beforeEach } from 'bun:test' + +describe('API sanity checks', () => { + beforeEach(async () => { + await fetch('http://localhost:6969/dev/re-seed', { method: 'POST' }) + }) + + test('GET /authors', async () => { + const response = await fetch('http://localhost:6969/authors') + const data = await response.json() + + expect(data).toHaveLength(8) + }) + + test('GET /books', async () => { + const response = await fetch('http://localhost:6969/books') + const data = await response.json() + + expect(data).toHaveLength(129) + }) + + test('GET /author/:uuid', async () => { + const response = await fetch('http://localhost:6969/authors') + const data = (await response.json()) as { uuid: string }[] + const authorUuid = data[0].uuid + + const authorResponse = await fetch(`http://localhost:6969/author/${authorUuid}`) + const authorData = await authorResponse.json() + + expect(authorData).toEqual({ + name: 'J.K. Rowling', + bio: 'British author, best known for the Harry Potter series.', + published_books: 20, + uuid: authorUuid, + }) + }) + + test('GET /author/:uuid/books', async () => { + const response = await fetch('http://localhost:6969/authors') + const data = (await response.json()) as { uuid: string }[] + const authorUuid = data[0].uuid + + const authorBooksResponse = await fetch(`http://localhost:6969/author/${authorUuid}/books`) + const authorBooksData = await authorBooksResponse.json() + + expect(authorBooksData).toHaveLength(20) + }) + + test('POST /author', async () => { + const response = await fetch('http://localhost:6969/author', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: 'New Author', + bio: 'New author bio', + }), + }) + + expect(response.status).toBe(201) + }) + + test('POST /book', async () => { + const response = await fetch('http://localhost:6969/authors') + const data = (await response.json()) as { uuid: string }[] + const authorUuid = data[0].uuid + + const bookResponse = await fetch('http://localhost:6969/book', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + title: 'New Book', + isbn: '1234567890', + published_date: '2022-01-01', + author_uuid: authorUuid, + }), + }) + + expect(bookResponse.status).toBe(201) + }) + + test('POST /author, POST /book on author, GET /books from author', async () => { + const authorResponse = await fetch('http://localhost:6969/author', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: 'Author with book', + bio: 'Author with book bio', + }), + }) + const authorData = (await authorResponse.json()) as { uuid: string } + const authorUuid = authorData.uuid + + const bookResponse = await fetch('http://localhost:6969/book', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + title: 'Book from author', + isbn: '1234567890', + published_date: '2022-01-01', + author_uuid: authorUuid, + }), + }) + expect(bookResponse.status).toBe(201) + + const authorBooksResponse = await fetch(`http://localhost:6969/author/${authorUuid}/books`) + const authorBooksData = await authorBooksResponse.json() + expect(authorBooksData).toHaveLength(1) + }) +}) diff --git a/src/server.ts b/src/server.ts index 040c7f6..d7c586a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,9 @@ import { Elysia, t } from 'elysia' import { client, initDb } from './db.ts' import swagger from '@elysiajs/swagger' +import { DbAuthor, DbBook } from './db-types.ts' +import path from 'path' +import { cwd } from 'node:process' await initDb() @@ -19,10 +22,10 @@ const app = new Elysia() .get( 'authors', async () => { - const result = await client.query('SELECT * FROM authors') + const result = await client.query('SELECT * FROM authors') return result.rows.map((it) => ({ - author_id: it.author_id, + uuid: it.uuid, name: it.name, bio: it.bio, })) @@ -30,7 +33,7 @@ const app = new Elysia() { response: t.Array( t.Object({ - author_id: t.String(), + uuid: t.String(), name: t.String(), bio: t.String(), }), @@ -39,9 +42,9 @@ const app = new Elysia() }, ) .get( - 'author/:id', - async ({ params: { id } }) => { - const result = await client.query('SELECT * FROM authors WHERE author_id = $1', [id]) + 'author/:uuid', + async ({ params: { uuid } }) => { + const result = await client.query('SELECT * FROM authors WHERE uuid = $1', [uuid]) if (result.rows.length === 0) { return null @@ -50,7 +53,7 @@ const app = new Elysia() const author = result.rows[0] const publishedBooks = await client.query('SELECT COUNT(*) FROM books WHERE author_id = $1', [author.id]) return { - author_id: author.author_id, + uuid: author.uuid, name: author.name, bio: author.bio, published_books: +publishedBooks.rows[0].count, @@ -60,7 +63,7 @@ const app = new Elysia() response: t.Nullable( t.Object( { - author_id: t.String(), + uuid: t.String(), name: t.String(), bio: t.String(), published_books: t.Number({ description: 'Number of published books' }), @@ -78,13 +81,15 @@ const app = new Elysia() return null } - const newAuthor = await client.query('INSERT INTO authors (name, bio) VALUES ($1, $2) RETURNING author_id', [ - body.name, - body.bio, - ]) + const newAuthor = await client.query<{ uuid: string }>( + 'INSERT INTO authors (name, bio) VALUES ($1, $2) RETURNING uuid', + [body.name, body.bio], + ) + + set.status = 'Created' - const newAuthorId: string = newAuthor.rows[0].author_id - return { author_id: newAuthorId } + const newAuthorId: string = newAuthor.rows[0].uuid + return { uuid: newAuthorId } }, { body: t.Object({ @@ -93,25 +98,25 @@ const app = new Elysia() }), response: t.Nullable( t.Object({ - author_id: t.String(), + uuid: t.String(), }), ), }, ) .delete( - 'author/:id', - async ({ params: { id }, set }) => { - if (await client.query('SELECT * FROM authors WHERE author_id = $1', [id]).then((it) => it.rows.length === 0)) { + 'author/:uuid', + async ({ params: { uuid }, set }) => { + if (await client.query('SELECT * FROM authors WHERE uuid = $1', [uuid]).then((it) => it.rows.length === 0)) { set.status = 'Not Found' return null } await client.query('BEGIN') - const author = await client.query('SELECT * FROM authors WHERE author_id = $1', [id]) - const authorId = author.rows[0].id + const author = await client.query('SELECT * FROM authors WHERE uuid = $1', [uuid]) + const id = author.rows[0].id - const deletedBooks = await client.query('DELETE FROM books WHERE author_id = $1', [authorId]) - const deletedAuthor = await client.query('DELETE FROM authors WHERE id = $1', [authorId]) + const deletedBooks = await client.query('DELETE FROM books WHERE author_id = $1', [id]) + const deletedAuthor = await client.query('DELETE FROM authors WHERE id = $1', [id]) await client.query('COMMIT') console.info(`Deleted ${deletedBooks.rowCount} books and ${deletedAuthor.rowCount} author`) @@ -123,14 +128,13 @@ const app = new Elysia() }, ) .get( - 'author/:id/books', - async ({ params: { id } }) => { - const result = await client.query( - `SELECT * - FROM books + 'author/:uuid/books', + async ({ params: { uuid } }) => { + const result = await client.query>( + `SELECT title, published_date, isbn FROM books LEFT JOIN authors a on a.id = books.author_id - WHERE a.author_id = $1`, - [id], + WHERE a.uuid = $1`, + [uuid], ) return result.rows.map((it) => ({ @@ -153,7 +157,7 @@ const app = new Elysia() .get( 'books', async () => { - const result = await client.query('SELECT * FROM books') + const result = await client.query('SELECT * FROM books') return result.rows.map((it) => ({ title: it.title, @@ -180,13 +184,15 @@ const app = new Elysia() return null } - const author = await client.query('SELECT * FROM authors WHERE author_id = $1', [body.author_id]) - const newBook = await client.query( - 'INSERT INTO books (isbn, title, published_date, author_id) VALUES ($1, $2, $3, $4) RETURNING book_id', + const author = await client.query('SELECT * FROM authors WHERE uuid = $1', [body.author_uuid]) + const newBook = await client.query>( + 'INSERT INTO books (isbn, title, published_date, author_id) VALUES ($1, $2, $3, $4) RETURNING uuid', [body.isbn, body.title, body.published_date, author.rows[0].id], ) - const newBookId: string = newBook.rows[0].book_id + set.status = 'Created' + + const newBookId: string = newBook.rows[0].uuid return { book_id: newBookId } }, { @@ -194,7 +200,7 @@ const app = new Elysia() isbn: t.String(), title: t.String(), published_date: t.String({ description: 'ISO8601 Date' }), - author_id: t.String({ description: 'UUID of author' }), + author_uuid: t.String({ description: 'UUID of author' }), }), response: t.Nullable( t.Object({ @@ -203,6 +209,16 @@ const app = new Elysia() ), }, ) + .post('dev/re-seed', async () => { + await client.query('BEGIN') + await client.query('DELETE FROM books') + await client.query('DELETE FROM authors') + await client.query('ALTER SEQUENCE authors_id_seq RESTART WITH 1') + await client.query(await Bun.file(path.join(cwd(), 'db', 'seed.sql')).text()) + await client.query('COMMIT') + + return { success: true } + }) .onError((err) => { console.error(err) }) diff --git a/tsconfig.json b/tsconfig.json index 302b1ee..e0d8861 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,6 @@ { "compilerOptions": { - "lib": [ - "ESNext" - ], + "lib": ["ESNext"], "module": "esnext", "target": "esnext", "moduleResolution": "bundler", @@ -17,8 +15,6 @@ "allowSyntheticDefaultImports": true, "forceConsistentCasingInFileNames": true, "allowJs": true, - "types": [ - "@types/bun" - ] - } + "types": ["bun-types"], + }, }