From afff570914102d5fb9b21f83a6e406cdf01b8ad7 Mon Sep 17 00:00:00 2001 From: Gianluca Acerbis Date: Wed, 3 Apr 2024 19:32:11 +0200 Subject: [PATCH 1/3] feat: add drizzle schema files --- .eslintrc.cjs | 3 ++- drizzle.config.ts | 10 ++++++++++ package.json | 6 ++++-- scripts/seed.ts | 9 +++++++++ src/libs/database.ts | 16 ++++++++++++++++ src/models/category.ts | 3 +++ src/models/product.ts | 3 +++ src/schema/categories.ts | 12 ++++++++++++ src/schema/products.ts | 21 +++++++++++++++++++++ 9 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 drizzle.config.ts create mode 100644 scripts/seed.ts create mode 100644 src/libs/database.ts create mode 100644 src/models/category.ts create mode 100644 src/models/product.ts create mode 100644 src/schema/categories.ts create mode 100644 src/schema/products.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index a78f97d9..4d6bff7b 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -10,8 +10,9 @@ module.exports = { 'plugin:@typescript-eslint/recommended', 'prettier', 'plugin:prettier/recommended', + 'plugin:drizzle/recommended' ], - plugins: ['@typescript-eslint'], + plugins: ['@typescript-eslint', 'drizzle'], rules: { '@typescript-eslint/consistent-type-definitions': ['error', 'type'], }, diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 00000000..fd3b432e --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,10 @@ +import type { Config } from "drizzle-kit"; + +export default { + schema: "src/schema/*.ts", + out: "drizzle", + driver: 'better-sqlite', + dbCredentials: { + url: 'data/sqlite.db', + } +} satisfies Config; diff --git a/package.json b/package.json index 4b29963f..67b66592 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,10 @@ "build": "bun build src/index.ts", "start": "NODE_ENV=production bun src/index.ts", "test": "bun test", - "lint": "eslint src test --fix --ext .ts", + "lint": "eslint src --fix --ext .ts", "studio": "drizzle-kit studio", - "push": "drizzle-kit push:sqlite" + "push": "drizzle-kit push:sqlite", + "fill-database": "bun scripts/seed.ts" }, "dependencies": { "drizzle-orm": "^0.30.6", @@ -24,6 +25,7 @@ "drizzle-kit": "^0.20.14", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", + "eslint-plugin-drizzle": "^0.2.3", "eslint-plugin-prettier": "^5.1.3", "prettier": "^3.2.5" }, diff --git a/scripts/seed.ts b/scripts/seed.ts new file mode 100644 index 00000000..4b255bd5 --- /dev/null +++ b/scripts/seed.ts @@ -0,0 +1,9 @@ +import {db} from "../src/libs/database.ts"; +import {categories} from "../src/schema/categories.ts"; + +await db.insert(categories).values([ + {name: 'Electronic'}, + {name: 'Books'} +]); + +console.log('Seeding complete.'); diff --git a/src/libs/database.ts b/src/libs/database.ts new file mode 100644 index 00000000..a74f0150 --- /dev/null +++ b/src/libs/database.ts @@ -0,0 +1,16 @@ +import { drizzle } from 'drizzle-orm/bun-sqlite'; +import { Database } from 'bun:sqlite'; +import type { SQLiteSelect } from 'drizzle-orm/sqlite-core'; +import * as products from '../schema/products.ts'; +import * as categories from '../schema/categories.ts'; + +const sqlite = new Database('data/sqlite.db'); +export const db = drizzle(sqlite, { schema: { ...products, ...categories } }); + +export const withPagination = ( + query: T, + page: number, + pageSize: number, +) => { + return query.limit(pageSize).offset(page * pageSize); +}; diff --git a/src/models/category.ts b/src/models/category.ts new file mode 100644 index 00000000..d8c38d60 --- /dev/null +++ b/src/models/category.ts @@ -0,0 +1,3 @@ +import { categories } from '../schema/categories.ts'; + +export type Category = typeof categories.$inferSelect; diff --git a/src/models/product.ts b/src/models/product.ts new file mode 100644 index 00000000..224ee2cb --- /dev/null +++ b/src/models/product.ts @@ -0,0 +1,3 @@ +import { products } from '../schema/products.ts'; + +export type Product = typeof products.$inferSelect; diff --git a/src/schema/categories.ts b/src/schema/categories.ts new file mode 100644 index 00000000..e27f0d6c --- /dev/null +++ b/src/schema/categories.ts @@ -0,0 +1,12 @@ +import { text, integer, sqliteTable } from 'drizzle-orm/sqlite-core'; +import { relations } from 'drizzle-orm'; +import { products } from './products.ts'; + +export const categories = sqliteTable('categories', { + id: integer('id').primaryKey(), + name: text('name').notNull().unique(), +}); + +export const categoriesRelations = relations(categories, ({ many }) => ({ + products: many(products), +})); diff --git a/src/schema/products.ts b/src/schema/products.ts new file mode 100644 index 00000000..9b574ab6 --- /dev/null +++ b/src/schema/products.ts @@ -0,0 +1,21 @@ +import { text, integer, sqliteTable, real } from 'drizzle-orm/sqlite-core'; +import { categories } from './categories.ts'; +import { relations } from 'drizzle-orm'; + +export const products = sqliteTable('products', { + id: integer('id').primaryKey(), + name: text('name').notNull(), + imageUrl: text('image_url'), + price: real('price').notNull(), + quantity: integer('quantity').notNull(), + category: integer('category_id') + .references(() => categories.id) + .notNull(), +}); + +export const productsRelations = relations(products, ({ one }) => ({ + category: one(categories, { + fields: [products.category], + references: [categories.id], + }), +})); From a12b95d76b073053439f56cf74581d9f882aeb77 Mon Sep 17 00:00:00 2001 From: Gianluca Acerbis Date: Wed, 3 Apr 2024 19:32:36 +0200 Subject: [PATCH 2/3] feat: add products and categories controllers and services --- src/controllers/category.ts | 17 +++++++++++++++++ src/controllers/products.ts | 24 ++++++++++++++++++++++++ src/index.ts | 7 +++++++ src/models/pagination.ts | 18 ++++++++++++++++++ src/services/category.ts | 18 ++++++++++++++++++ src/services/product.ts | 18 ++++++++++++++++++ src/setup.ts | 4 ++++ 7 files changed, 106 insertions(+) create mode 100644 src/controllers/category.ts create mode 100644 src/controllers/products.ts create mode 100644 src/index.ts create mode 100644 src/models/pagination.ts create mode 100644 src/services/category.ts create mode 100644 src/services/product.ts create mode 100644 src/setup.ts diff --git a/src/controllers/category.ts b/src/controllers/category.ts new file mode 100644 index 00000000..e0e7ecac --- /dev/null +++ b/src/controllers/category.ts @@ -0,0 +1,17 @@ +import { Elysia } from 'elysia'; +import { setup } from '../setup.ts'; +import { CategoryService } from '../services/category.ts'; + +export const categories = new Elysia({ + name: 'Controller.Categories', + prefix: '/categories', +}) + .use(setup) + .get('/', async () => { + const categories = await CategoryService.getAllCategoriesWithProductQuantity(); + + return { + results: categories, + size: categories.length, + }; + }); diff --git a/src/controllers/products.ts b/src/controllers/products.ts new file mode 100644 index 00000000..f9360ae3 --- /dev/null +++ b/src/controllers/products.ts @@ -0,0 +1,24 @@ +import { Elysia } from 'elysia'; +import { setup } from '../setup.ts'; +import { ProductService } from '../services/product.ts'; + +export const products = new Elysia({ + name: 'Controller.Products', + prefix: '/products', +}) + .use(setup) + .get( + '/', + async ({ query: { offset, limit } }) => { + const availableProducts = await ProductService.getAllAvailableProducts({ + offset, + limit, + }); + + return { + results: availableProducts, + size: availableProducts.length, + }; + }, + { query: 'pagination.options' }, + ); diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..9eeac8d7 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,7 @@ +import { Elysia } from 'elysia'; +import { products } from './controllers/products.ts'; +import { categories } from './controllers/category.ts'; + +const app = new Elysia({ prefix: '/api' }).use(products).use(categories).listen(3000); + +console.log(`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`); diff --git a/src/models/pagination.ts b/src/models/pagination.ts new file mode 100644 index 00000000..ea76a667 --- /dev/null +++ b/src/models/pagination.ts @@ -0,0 +1,18 @@ +import { Elysia, type Static, t } from 'elysia'; + +export const paginationOptions = t.Object({ + offset: t.Optional( + t.Numeric({ minimum: 0, error: 'Must be a number greater than or equal to 0' }), + ), + limit: t.Optional( + t.Numeric({ minimum: 1, error: 'Must be a number greater than or equal to 1' }), + ), +}); + +export type PaginationOptions = Static; + +export const paginationModels = new Elysia({ + name: 'Model.Pagination', +}).model({ + 'pagination.options': paginationOptions, +}); diff --git a/src/services/category.ts b/src/services/category.ts new file mode 100644 index 00000000..47d36a26 --- /dev/null +++ b/src/services/category.ts @@ -0,0 +1,18 @@ +import { db } from '../libs/database.ts'; +import { categories } from '../schema/categories.ts'; +import { count, eq } from 'drizzle-orm'; +import { products } from '../schema/products.ts'; + +export abstract class CategoryService { + public static getAllCategoriesWithProductQuantity() { + return db + .select({ + id: categories.id, + name: categories.name, + numOfProducts: count(products.id), + }) + .from(categories) + .leftJoin(products, eq(categories.id, products.category)) + .groupBy(categories.id, categories.name); + } +} diff --git a/src/services/product.ts b/src/services/product.ts new file mode 100644 index 00000000..8f6bf513 --- /dev/null +++ b/src/services/product.ts @@ -0,0 +1,18 @@ +import type { Product } from '../models/product.ts'; +import { db } from '../libs/database.ts'; +import type { PaginationOptions } from '../models/pagination.ts'; + +export abstract class ProductService { + public static getAllAvailableProducts( + paginationOptions: PaginationOptions, + ): Promise { + const limit = paginationOptions.limit ?? 25; + const offset = paginationOptions.offset ?? 0; + + return db.query.products.findMany({ + where: (products, { gt }) => gt(products.quantity, 0), + limit, + offset, + }); + } +} diff --git a/src/setup.ts b/src/setup.ts new file mode 100644 index 00000000..7b86f62b --- /dev/null +++ b/src/setup.ts @@ -0,0 +1,4 @@ +import { Elysia } from 'elysia'; +import { paginationModels } from './models/pagination.ts'; + +export const setup = new Elysia({ name: 'setup' }).use(paginationModels); From bd06fdf0b4163406b729911f33e8eb8edf17ae0a Mon Sep 17 00:00:00 2001 From: Gianluca Acerbis Date: Wed, 3 Apr 2024 19:33:08 +0200 Subject: [PATCH 3/3] docs: add openapi docs for products and categories controllers --- docs/api.yaml | 156 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/api.yaml diff --git a/docs/api.yaml b/docs/api.yaml new file mode 100644 index 00000000..b6e78e88 --- /dev/null +++ b/docs/api.yaml @@ -0,0 +1,156 @@ +openapi: 3.0.3 +info: + title: FreshCart Market API + description: FreshCart Market API documentation + version: 1.0.0 +servers: + - url: 'http://localhost:3000/api' + description: Local server +paths: + /products: + get: + summary: Get all available products + operationId: getAllAvailableProducts + description: Get all available products (quantity > 0). + tags: + - Products + parameters: + - $ref: '#/components/parameters/paginationOffset' + - $ref: '#/components/parameters/paginationLimit' + responses: + '200': + description: A list of products. + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/Product' + size: + type: integer + description: The number of products in the result set. + required: + - results + - size + '422': + $ref: '#/components/responses/ValidationError' + /categories: + get: + summary: Get all categories + operationId: getAllCategories + description: Get all categories with the number of products assigned to them. + tags: + - Categories + responses: + '200': + description: A list of categories. + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: + allOf: + - $ref: '#/components/schemas/Category' + - type: object + properties: + numOfProducts: + type: integer + description: The number of products assigned to the category. + required: + - numOfProducts + size: + type: integer + description: The number of categories in the result set. + required: + - results + - size +components: + parameters: + paginationOffset: + in: query + name: offset + required: false + schema: + type: integer + minimum: 0 + default: 0 + description: The number of items to skip before starting to collect the result set. + paginationLimit: + in: query + name: limit + required: false + schema: + type: integer + minimum: 1 + default: 25 + description: The numbers of items to return. + schemas: + Category: + type: object + properties: + id: + type: integer + format: int64 + description: The category ID. + readOnly: true + name: + type: string + description: The category name. + example: Fruits + required: + - id + - name + Product: + type: object + properties: + id: + type: integer + format: int64 + description: The product ID. + readOnly: true + name: + type: string + description: The product name. + price: + type: number + format: float + description: The product price. + quantity: + type: integer + description: The product quantity. + category: + type: integer + description: The category ID. + imageUrl: + type: string + format: uri + description: The image url. + nullable: true + required: + - id + - name + - price + - quantity + - category + example: + id: 1 + name: Apple + price: 1.99 + quantity: 100 + imageUrl: null + category: 2 + responses: + ValidationError: + description: The parameters or the body of the request are invalid. + content: + text/plain: + schema: + type: string + example: 'Invalid parameters or request body.' +security: []