diff --git a/openapi.json b/openapi.json new file mode 100644 index 0000000..ba29648 --- /dev/null +++ b/openapi.json @@ -0,0 +1,653 @@ +{ + "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": { + "type": "apiKey", + "in": "cookie", + "name": "accessToken" + } + }, + "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": null + } + ] + } + } + } + } + }, + "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": { + "type": "array", + "description": "List of new images to upload", + "items": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "update product success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + }, + "example": null + } + } + } + } + } + } + } +} diff --git a/package-lock.json b/package-lock.json index c45be09..843482c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "nodemailer": "^6.9.15", "pino": "^9.5.0", "pino-http": "^10.3.0", + "swagger-ui-express": "^5.0.1", "xss-filters": "^1.2.7", "zod": "^3.23.8" }, @@ -38,6 +39,7 @@ "@types/multer": "^1.4.12", "@types/node": "^22.5.4", "@types/nodemailer": "^6.4.16", + "@types/swagger-ui-express": "^4.1.7", "@types/xss-filters": "^0.0.30", "husky": "^9.1.5", "jest": "^29.7.0", @@ -1406,6 +1408,13 @@ "@prisma/debug": "5.20.0" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -1743,6 +1752,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.7.tgz", + "integrity": "sha512-ovLM9dNincXkzH4YwyYpll75vhzPBlWx6La89wwvYH7mHjVpf0X0K/vR/aUM7SRxmr5tt9z7E5XJcjQ46q+S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "node_modules/@types/xss-filters": { "version": "0.0.30", "resolved": "https://registry.npmjs.org/@types/xss-filters/-/xss-filters-0.0.30.tgz", @@ -6924,6 +6944,30 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-ui-dist": { + "version": "5.18.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.18.2.tgz", + "integrity": "sha512-J+y4mCw/zXh1FOj5wGJvnAajq6XgHOyywsa9yITmwxIlJbMqITq3gYRZHaeqLVH/eV/HOPphE6NjF+nbSNC5Zw==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", diff --git a/package.json b/package.json index 15e4dfe..4c7676a 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "nodemailer": "^6.9.15", "pino": "^9.5.0", "pino-http": "^10.3.0", + "swagger-ui-express": "^5.0.1", "xss-filters": "^1.2.7", "zod": "^3.23.8" }, @@ -51,6 +52,7 @@ "@types/multer": "^1.4.12", "@types/node": "^22.5.4", "@types/nodemailer": "^6.4.16", + "@types/swagger-ui-express": "^4.1.7", "@types/xss-filters": "^0.0.30", "husky": "^9.1.5", "jest": "^29.7.0", diff --git a/src/index.ts b/src/index.ts index 61875c7..08054b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,8 +2,10 @@ import cookieParser from 'cookie-parser' import express from 'express' import 'dotenv/config' import cors from 'cors' +import swaggerUI from 'swagger-ui-express' import sanitizeInput from './middlewares/sanitizeInput' const app = express() +import swaggerDocument from '../openapi.json' import routes from './routes' const port = process.env.SERVER_PORT @@ -16,6 +18,7 @@ app.use(cors()) app.use(express.json()) app.use(cookieParser()) app.use(sanitizeInput) +app.use('/api/docs', swaggerUI.serve, swaggerUI.setup(swaggerDocument)) app.use('/api/', routes) app.listen(port, () => { console.info(`Server running at port ${port}`)