diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..80e9bfb --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,433 @@ + +openapi: 3.0.3 +info: + title: Juadah API + description: E-commerce for backery + version: 0.3.0 +servers: + - url: https://juadah-backend.vercel.app + - url: http://localhost:3000 +tags: + - name: Authentication + - name: Products +components: + securitySchemes: + cookieAuth: # arbitrary name for the security scheme; will be used in the "security" key later + type: apiKey + in: cookie + name: accessToken # cookie name + headers: + Set-Cookie-accessToken: + description: access token cookie. + schema: + type: string + example: accessToken=abcde12345; Path=/; HttpOnly + Set-Cookie-refreshToken: + description: refresh token cookie. + schema: + type: string + example: refreshToken=token12345; Path=/api/refresh; Secure + examples: + ValidationError: + value: + status: fail + errors: + code: 400 + message: validation error + details: + email: invalid email format + password: password should have minimum 6 characters length + InvalidCredentials: + value: + status: fail + errors: + code: 400 + message: email or password is incorrect + NotFound: + value: + status: fail + errors: + code: 404 + message: resource you're looking for is not found + schemas: + CreateProducts: + required: + - name + - price + type: object + properties: + name: + type: string + description: + type: string + price: + type: number + images: + type: string + format: binary + ErrorResponse: + type: object + properties: + status: + type: string + errors: + type: object + properties: + code: + type: number + message: + type: string + details: + type: object + required: + - code + - message + SuccessResponse: + type: object + properties: + status: + type: string + example: success + data: + type: object + +paths: + /api/register: + post: + summary: "create new account" + tags: + - Authentication + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + fullname: + type: string + password: + type: string + passwordConfirmation: + type: string + responses: + '201': + description: account created successfully + headers: + Set-Cookie-accessToken: + $ref: '#/components/headers/Set-Cookie-accessToken' + Set-Cookie-refreshToken: + $ref: '#/components/headers/Set-Cookie-refreshToken' + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + example: + status: success + data: + users: + id: 1 + fullname: zulfikar + email: email@example.com + '400': + description: bad request - validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + validation error: + $ref: '#/components/examples/ValidationError' + /api/login: + post: + summary: login to existing account + tags: + - Authentication + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + email: + type: string + password: + type: string + responses: + '200': + description: successfully login to existing account + headers: + Set-Cookie-accessToken: + $ref: '#/components/headers/Set-Cookie-accessToken' + Set-Cookie-refreshToken: + $ref: '#/components/headers/Set-Cookie-refreshToken' + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + example: + status: success + data: + users: + id: 1 + fullname: zulfikar + email: email@example.com + '400': + description: validation error or invalid credentials + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + validation error: + $ref: '#/components/examples/ValidationError' + invalid email or password: + $ref: '#/components/examples/InvalidCredentials' + /api/refresh: + get: + summary: renew access token + tags: + - Authentication + parameters: + - in: cookie + name: refreshToken + required: true + schema: + type: string + example: token123 + responses: + '200': + description: successfully renew access token + headers: + Set-Cookie-accessToken: + $ref: '#/components/headers/Set-Cookie-accessToken' + Set-Cookie-refreshToken: + $ref: '#/components/headers/Set-Cookie-refreshToken' + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + example: + status: success + data: + users: + id: 1 + fullname: zulfikar + email: email@example.com + '400': + description: bad request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + empty refresh token: + value: + status: fail + errors: + code: 400 + message: invalid request, refresh token unavailable + invalid refresh token: + value: + status: fail + errors: + code: 400 + message: invalid request, refresh token is invalid + /api/products: + get: + summary: get products with infinite scroll features + description: this is done because we have infinite scrolling endpoint, you can request the infinite products by adding query parameter + parameters: + - in: query + required: false + name: last_id + description: by providing the last_id query params, you can get the next 50 products from the last id you provided here. for example if you put last_id 50 you'll receive the next 50 products from 51-100. + schema: + type: number + example: 50 + tags: + - Products + security: + - cookieAuth: [] + responses: + 200: + description: successfully get the first 50 products + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + example: + status: success + data: + meta: + lastProductId: 2 + products: + - id: 1 + name: Kue Bolu + description: Kue bolu paling enak + price: 10000 + images: + - path/to/images.png + - path/to/images.png + - id: 2 + name: Kue Kotak + description: null + price: 10000 + images: + 404: + description: no products found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + not found: + $ref: '#/components/examples/NotFound' + post: + summary: create new product + tags: + - Products + security: + - cookieAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/CreateProducts' + responses: + 201: + description: products created + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + example: + status: success + data: + products: + id: 1 + name: Kue Bolu + description: Kue paling enak + price: 10000 + images: + - path/to/image.png + - path/to/image.jpg + 400: + description: validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + status: fail + errors: + code: 400 + message: validation error + details: + name: product name cannot be empty + price: product price cannot be empty + images: image extension is not supported + 500: + description: server error + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + status: 'fail' + errors: + code: 500 + message: fail to create product and this is not your fault, please try again later + /api/products/{id}: + get: + tags: + - Products + security: + - cookieAuth: [] + summary: get product by id + parameters: + - in: path + name: id + description: product id + schema: + type: number + required: true + example: 1 + + responses: + 200: + description: product found + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + example: + status: success + data: + products: + id: 1 + name: Kue Bolu + description: Kue bolu paling enak + price: 1000 + images: + - path/to/image.png + 404: + description: product with specified id is not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + not found: + $ref: '#/components/examples/NotFound' + put: + summary: update product + parameters: + - in: path + name: id + description: product id + schema: + type: number + required: true + example: 1 + security: + - cookieAuth: [] + tags: + - Products + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + name: + type: string + description: + type: string + price: + type: number + images.removed: + type: array + description: List of image identifiers to remove + items: + type: string + images.new: + type: array + description: List of new images to upload + items: + type: string + format: binary + images: + type: array + items: + type: string + format: binary + responses: + 200: + description: update product success + diff --git a/package.json b/package.json index 4c7676a..54db23c 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "test": "npx jest", "postinstall": "prisma generate", "build": "npx prisma generate && npx tsc", - "dev": "npx ts-node-dev --respawn --transpile-only src/index.ts", + "dev": "npx ts-node-dev --respawn --rs --transpile-only src/index.ts", "lint": "npx biome lint ./src", "fix-lint": "npx biome lint --write --unsafe ./src", "format": "npx biome format --write ./src", diff --git a/src/index.ts b/src/index.ts index 08054b3..7b92b18 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import cors from 'cors' import swaggerUI from 'swagger-ui-express' import sanitizeInput from './middlewares/sanitizeInput' const app = express() -import swaggerDocument from '../openapi.json' +import swaggerDocument from './openapi.json' import routes from './routes' const port = process.env.SERVER_PORT diff --git a/src/lib/Error.ts b/src/lib/Error.ts index af5e507..fff5f22 100644 --- a/src/lib/Error.ts +++ b/src/lib/Error.ts @@ -33,3 +33,15 @@ export class ServerError extends Error { this.code = 500 } } + +/** + * this is needed for image error validation and for create product price error validation + * we receive multipart/form-data and every input is a string + */ +export class CustomValidationError extends Error { + public fieldname: string + constructor(message: string, fieldname: string) { + super(message) + this.fieldname = fieldname + } +} diff --git a/src/lib/upload.ts b/src/lib/upload.ts index ea63c60..e23896d 100644 --- a/src/lib/upload.ts +++ b/src/lib/upload.ts @@ -4,6 +4,7 @@ import type { Request } from 'express' import multer from 'multer' import { CloudinaryStorage } from 'multer-storage-cloudinary' import 'dotenv/config' +import { CustomValidationError } from './Error' cloudinary.config({ cloud_name: process.env.CLOUDINARY_CLOUD_NAME as string, @@ -23,10 +24,12 @@ const fileFilter = (_: Request, file: Express.Multer.File, callback: any) => { if (validFormat.includes(fileFormat) && mimetype.includes('image')) { return callback(null, true) } + console.log(file) return callback( - new Error( + new CustomValidationError( 'invalid file format, please only upload file with .png or .jpg extension', + file.fieldname, ), false, ) diff --git a/src/middlewares/formDataParser.ts b/src/middlewares/formDataParser.ts index 7350c79..b1a4f40 100644 --- a/src/middlewares/formDataParser.ts +++ b/src/middlewares/formDataParser.ts @@ -8,7 +8,10 @@ export default function formDataParse(multer: any) { status: 'fail', errors: { code: 400, - message: err.message, + message: 'validation errors', + details: { + [err.fieldname]: err.message, + }, }, }) } diff --git a/src/middlewares/validateInput.ts b/src/middlewares/validateInput.ts index 9affd44..eaeaafe 100644 --- a/src/middlewares/validateInput.ts +++ b/src/middlewares/validateInput.ts @@ -12,6 +12,7 @@ export function validateInput(schema: AnyZodObject) { schema.parse(req.body) return next() } catch (error: any) { + console.log(error) return res.status(400).send({ status: 'fail', errors: { diff --git a/openapi.json b/src/openapi.json similarity index 89% rename from openapi.json rename to src/openapi.json index ba29648..34e32c9 100644 --- a/openapi.json +++ b/src/openapi.json @@ -642,7 +642,64 @@ "schema": { "$ref": "#/components/schemas/SuccessResponse" }, - "example": null + "example": { + "status": "success", + "data": { + "products": { + "id": 1, + "name": "updated kue bolu", + "description": "updated kue bolu description", + "price": 15000, + "images": ["new/image/path.png"] + } + } + } + } + } + }, + "400": { + "description": "fail to update, validaiton error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "image error": { + "value": { + "status": "fail", + "errors": { + "code": 400, + "message": "each product can only have 5 images at max" + } + } + }, + "validation errors": { + "value": { + "status": "fail", + "errors": { + "code": 400, + "message": "validation errors", + "details": { + "price": "price should be a number", + "name": "name is required" + } + } + } + }, + "image not supported": { + "value": { + "status": "fail", + "errors": { + "code": 400, + "message": "validation errors", + "details": { + "images": "invalid file format, please only upload file with .png or .jpg extension" + } + } + } + } + } } } } diff --git a/src/product/handler.ts b/src/product/handler.ts index 9931df2..e44bcad 100644 --- a/src/product/handler.ts +++ b/src/product/handler.ts @@ -16,6 +16,7 @@ interface ProductService { id: string, data: UpdateProductDataService, ): Promise> + deleteProductById(id: string): Promise> } class ProductHandler { @@ -82,6 +83,14 @@ class ProductHandler { } return res.status(200).json(result) } + + deleteProductById = async (req: Request<{ id: string }>, res: Response) => { + const result = await this.service.deleteProductById(req.params.id) + if (result.status === 'fail') { + return res.status(404).json(result) + } + return res.sendStatus(204) + } } export default ProductHandler diff --git a/src/product/schema.ts b/src/product/schema.ts index 5733ca7..e6fd0ac 100644 --- a/src/product/schema.ts +++ b/src/product/schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod' +import { ZodError, z } from 'zod' export const createProduct = z.object({ name: z @@ -7,33 +7,26 @@ export const createProduct = z.object({ description: z .string({ message: 'product description is required' }) .min(1, 'product description is required'), - price: z.string().transform((val) => { - const valInNumber = Number.parseFloat(val) - if (Number.isNaN(valInNumber)) { - throw new Error('product price is invalid') - } - return valInNumber - }), + price: z.string(), images: z.string().array().optional(), }) export const updateProduct = z.object({ name: z.string({ required_error: 'name is required' }), description: z.string({ required_error: 'description is required' }), - price: z.string({ required_error: 'price is required' }).transform((val) => { - const valInNumber = Number.parseFloat(val) - if (Number.isNaN(valInNumber)) { - throw new Error('product price is invalid') - } - return valInNumber - }), + price: z.string({ required_error: 'price is required' }), images: z.object({ removed: z.array(z.string(), { required_error: 'this field is required' }), }), }) -export type CreateProduct = z.TypeOf -export type Product = z.TypeOf & { id: bigint } +export type CreateProduct = Omit, 'price'> & { + price: number +} +export type Product = Omit, 'price'> & { + id: bigint + price: number +} export type UpdateProduct = z.TypeOf export type UpdateProductDataService = { name: string diff --git a/src/product/service.ts b/src/product/service.ts index 9b10e87..6993bd6 100644 --- a/src/product/service.ts +++ b/src/product/service.ts @@ -1,5 +1,9 @@ import type { JsonValue } from '@prisma/client/runtime/library' -import { NotFoundError } from '../lib/Error' +import { + BadRequestError, + CustomValidationError, + NotFoundError, +} from '../lib/Error' import { type Logger, getContext } from '../lib/logger' import type ApiResponse from '../schema' import type { @@ -28,7 +32,7 @@ interface ProductRepository { id: bigint }[] > - deleteProductById(id: number): Promise<{ affectedRows: number }> + deleteProductById(id: bigint): Promise<{ affectedRows: number }> updateProductById( id: bigint, data: FlattenUpdateProduct, @@ -45,7 +49,29 @@ class ProductService { data: CreateProduct, ): Promise> => { try { - const newProduct = await this.repo.createProduct(data) + let priceInFloat: undefined | number + if (typeof data.price === 'string') { + priceInFloat = Number.parseFloat(data.price) + } else { + priceInFloat = data.price + } + if (Number.isNaN(priceInFloat) || !priceInFloat) { + const context = getContext() + this.logger( + 'error', + 'price parsing faiil', + 'service', + 'createProduct', + context, + ) + throw new BadRequestError( + 'fail to create product, make sure to insert correct information and try again', + ) + } + const newProduct = await this.repo.createProduct({ + ...data, + price: priceInFloat, + }) const result = await this.repo.getProductById(newProduct.id) return { @@ -114,8 +140,8 @@ class ProductService { } deleteProductById = async (id: string): Promise> => { - const parsedId = Number.parseInt(id, 10) try { + const parsedId = BigInt(id) if (Number.isNaN(parsedId)) { throw new NotFoundError( "you are trying to delete the product that does'nt exists", @@ -141,7 +167,7 @@ class ProductService { status: 'fail', errors: { code: 404, - message: error.message || error, + message: 'fail to delete product, product not found', }, } } @@ -158,6 +184,23 @@ class ProductService { "you are trying to update the product that doesn't exists", ) } + let priceInFloat: undefined | number + if (typeof data.price === 'string') { + priceInFloat = Number.parseFloat(data.price) + } else { + priceInFloat = data.price + } + if (Number.isNaN(priceInFloat) || !priceInFloat) { + const context = getContext() + this.logger( + 'error', + 'price parsing faiil', + 'service', + 'createProduct', + context, + ) + throw new CustomValidationError('price should be number', 'price') + } const oldProduct = await this.repo.getProductById(parsedId) const allImages = [ @@ -172,7 +215,7 @@ class ProductService { const updatedProduct = await this.repo.updateProductById(parsedId, { name: data.name, description: data.description, - price: data.price, + price: priceInFloat, images: finalImages, }) @@ -197,6 +240,18 @@ class ProductService { 'updateProductById', context, ) + if (error instanceof CustomValidationError) { + return { + status: 'fail', + errors: { + code: 400, + message: 'validation errors', + details: { + [error.fieldname]: error.message, + }, + }, + } + } if (error instanceof SyntaxError) { return { status: 'fail', diff --git a/src/routes.ts b/src/routes.ts index 1cdcdcd..2f8d95e 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -68,6 +68,7 @@ router.put( validateInput(updateProduct), productHandler.updateProductById, ) +router.delete('/products/:id', productHandler.deleteProductById) router.post('/products/:id/payments', paymentHandler.requestOrderToken) router.get('/products/payments/:orderId', paymentHandler.checkOrderStatus)