├── .env.example ├── .github └── workflows │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── .prettierignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── api └── index.ts ├── assets └── .gitkeep ├── bun.lockb ├── changelog.config.ts ├── commitlint.config.js ├── deploy.md ├── eslint.config.js ├── package.json ├── prettier.config.js ├── src ├── app.ts ├── common │ ├── helpers │ │ └── crypto.service.ts │ └── types │ │ ├── index.ts │ │ ├── redis.interface.ts │ │ ├── route.type.ts │ │ └── use-case.type.ts ├── infra │ ├── mongodb │ │ ├── dal.service.ts │ │ ├── index.ts │ │ ├── models │ │ │ ├── bodyparts │ │ │ │ ├── bodypart.entity.ts │ │ │ │ └── bodypart.schema.ts │ │ │ ├── equipments │ │ │ │ ├── equipment.entity.ts │ │ │ │ └── equipment.schema.ts │ │ │ ├── exercises │ │ │ │ ├── exercise.entity.ts │ │ │ │ └── exercise.schema.ts │ │ │ ├── muscles │ │ │ │ ├── muscle.entity.ts │ │ │ │ └── muscle.schema.ts │ │ │ └── users │ │ │ │ ├── user.entity.ts │ │ │ │ └── user.schema.ts │ │ └── plugins │ │ │ ├── index.ts │ │ │ ├── toJSON │ │ │ └── toJSON.ts │ │ │ └── toJSONWithoutId │ │ │ └── toJSONWithoutId.ts │ ├── redis │ │ ├── dal.redis.ts │ │ ├── redis.client.ts │ │ └── redis.service.ts │ └── supabase │ │ └── index.ts ├── middleware │ └── auth │ │ └── index.ts ├── modules │ ├── bodyparts │ │ ├── controllers │ │ │ ├── bodyPart.controller.ts │ │ │ └── index.ts │ │ ├── models │ │ │ └── bodyPart.model.ts │ │ ├── services │ │ │ ├── body-part.service.ts │ │ │ └── index.ts │ │ └── use-cases │ │ │ ├── create-bodypart │ │ │ ├── create-bodypart.use-case.ts │ │ │ └── index.ts │ │ │ └── get-bodyparts │ │ │ ├── get-bodypart.usecase.ts │ │ │ └── index.ts │ ├── equipments │ │ ├── controllers │ │ │ ├── equipment.controller.ts │ │ │ └── index.ts │ │ ├── models │ │ │ └── equipment.model.ts │ │ ├── services │ │ │ ├── equipment.service.ts │ │ │ └── index.ts │ │ └── use-cases │ │ │ ├── create-equipment │ │ │ ├── create-equipment.use-case.ts │ │ │ └── index.ts │ │ │ └── get-equipments │ │ │ ├── get-equipment.usecase.ts │ │ │ └── index.ts │ ├── exercises │ │ ├── controllers │ │ │ ├── exercise.controller.ts │ │ │ └── index.ts │ │ ├── models │ │ │ └── exercise.model.ts │ │ ├── services │ │ │ ├── exercise.service.ts │ │ │ └── index.ts │ │ └── use-cases │ │ │ ├── create-exercise │ │ │ ├── create-exercise.usecase.ts │ │ │ └── index.ts │ │ │ ├── get-autocomplete-suggestions │ │ │ ├── get-autocomplete-suggestions.usecase.ts │ │ │ └── index.ts │ │ │ ├── get-exercises-by-id │ │ │ ├── get-exercise-by-id.usecase.ts │ │ │ └── index.ts │ │ │ └── get-exercises │ │ │ ├── get-exercise.usecase.ts │ │ │ └── index.ts │ ├── images │ │ ├── controllers │ │ │ ├── image.controller.ts │ │ │ └── index.ts │ │ ├── services │ │ │ ├── image.service.ts │ │ │ └── index.ts │ │ └── use-cases │ │ │ ├── upload-image │ │ │ ├── index.ts │ │ │ └── upload-gif.usecase.ts │ │ │ └── view-image │ │ │ ├── index.ts │ │ │ └── view-image.usecase.ts │ ├── index.ts │ ├── muscles │ │ ├── controllers │ │ │ ├── index.ts │ │ │ └── muscle.controller.ts │ │ ├── models │ │ │ └── muscle.model.ts │ │ ├── services │ │ │ ├── index.ts │ │ │ └── muscle.service.ts │ │ └── use-cases │ │ │ ├── create-muscle │ │ │ ├── create-muscle.use-case.ts │ │ │ └── index.ts │ │ │ └── get-muscles │ │ │ ├── get-muscle.usecase.ts │ │ │ └── index.ts │ └── users │ │ ├── controllers │ │ ├── index.ts │ │ └── user.controller.ts │ │ ├── services │ │ ├── index.ts │ │ └── user.service.ts │ │ └── use-cases │ │ ├── authenticate │ │ ├── authenticate.usecase.ts │ │ └── index.ts │ │ ├── create-user │ │ ├── create-user.usercase.ts │ │ └── index.ts │ │ └── get-user │ │ ├── get-user.usecase.ts │ │ └── index.ts ├── pages │ └── home.tsx └── server.ts ├── tsconfig.json ├── vercel.json └── vitest.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | EXERCISEDB_DATABASE= 2 | 3 | NODE_ENV= 4 | 5 | EXERCISEDB_STORAGE_CACHE= 6 | 7 | #supabase creds 8 | SUPABASE_PROJECT_URL= 9 | 10 | SUPABASE_ANON_KEY= 11 | 12 | SUPABASE_BUCKET_URL= 13 | 14 | #JWT SECERET 15 | JWT_ACCESS_TOKEN_SECRET= 16 | JWT_ACCESS_TOKEN_EXPIRATION= 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | relase: 12 | name: Test and Deploy to Staging 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout Code 16 | uses: actions/checkout@v4 17 | 18 | - name: Cache Dependencies 19 | uses: actions/cache@v4.0.2 20 | with: 21 | path: ~/.bun 22 | key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }} 23 | restore-keys: ${{ runner.os }}-bun- 24 | 25 | - name: Setup Bun 26 | uses: oven-sh/setup-bun@v1.2.0 27 | - run: bun install 28 | 29 | # - name: Run Tests 30 | # run: bun run test 31 | 32 | - name: Deploy to Staging 33 | id: deploy-vercel-staging 34 | uses: amondnet/vercel-action@v25.2.0 35 | with: 36 | vercel-token: ${{ secrets.VERCEL_TOKEN }} 37 | vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} 38 | vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID_EXERCISEDB_STAGING}} 39 | scope: ${{ secrets.VERCEL_ORG_ID }} 40 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy To Production 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | 7 | jobs: 8 | deploy-production: 9 | name: Run Tests and Deploy to Production 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout Code 13 | uses: actions/checkout@v4 14 | 15 | - name: Cache Dependencies 16 | uses: actions/cache@v4.0.2 17 | with: 18 | path: ~/.bun 19 | key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lockb') }} 20 | restore-keys: ${{ runner.os }}-bun- 21 | 22 | - name: Setup Bun 23 | uses: oven-sh/setup-bun@v1.2.0 24 | - run: bun install 25 | 26 | # - name: Run Tests 27 | # run: bun run test 28 | 29 | - name: Deploy to Production 30 | uses: amondnet/vercel-action@v25.2.0 31 | id: deploy-vercel-production 32 | with: 33 | vercel-token: ${{ secrets.VERCEL_TOKEN }} 34 | vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} 35 | vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID_EXERCISEDB }} 36 | vercel-args: --prod 37 | scope: ${{ secrets.VERCEL_ORG_ID }} 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # deps 2 | node_modules/ 3 | .env 4 | dist 5 | seed 6 | .vercel 7 | staging.vercel 8 | production.vercel 9 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | dist 4 | lint-* -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | ## v0.0.7 5 | 6 | [compare changes](https://github.com/cyberboyanmol/exercisedb-api/compare/v0.0.6...v0.0.7) 7 | 8 | ## v0.0.6 9 | 10 | [compare changes](https://github.com/cyberboyanmol/exercisedb-api/compare/v0.0.5...v0.0.6) 11 | 12 | ### 🚀 Enhancements 13 | 14 | - **exercise:** Added endpoint to get exercise by exerciseid ([b3fa32a](https://github.com/cyberboyanmol/exercisedb-api/commit/b3fa32a)) 15 | 16 | ### ❤️ Contributors 17 | 18 | - Anmol Gangwar 19 | 20 | ## v0.0.5 21 | 22 | [compare changes](https://github.com/cyberboyanmol/exercisedb-api/compare/v0.0.4...v0.0.5) 23 | 24 | ## v0.0.4 25 | 26 | [compare changes](https://github.com/cyberboyanmol/exercisedb-api/compare/v0.0.3...v0.0.4) 27 | 28 | ### 🚀 Enhancements 29 | 30 | - **bodyPart:** Added route endpoint to get exercises by specific bodyPart ([387524d](https://github.com/cyberboyanmol/exercisedb-api/commit/387524d)) 31 | - **equipments:** ⚡️ added route endpoint to get exercises by specific equipment ([d5786f2](https://github.com/cyberboyanmol/exercisedb-api/commit/d5786f2)) 32 | - **muscles:** Added route endpoint to get exercises by specific muscle ([b3c8a9c](https://github.com/cyberboyanmol/exercisedb-api/commit/b3c8a9c)) 33 | 34 | ### 🩹 Fixes 35 | 36 | - 🐛 fixes invalid token error ([8c45438](https://github.com/cyberboyanmol/exercisedb-api/commit/8c45438)) 37 | 38 | ### ❤️ Contributors 39 | 40 | - Anmol Gangwar 41 | 42 | ## v0.0.3 43 | 44 | [compare changes](https://github.com/cyberboyanmol/exercisedb-api/compare/v0.0.2...v0.0.3) 45 | 46 | ### 🚀 Enhancements 47 | 48 | - **user:** Added user entity ([2bf3122](https://github.com/cyberboyanmol/exercisedb-api/commit/2bf3122)) 49 | - **authentication:** 🔒️ add register user and authenticate route ([779c652](https://github.com/cyberboyanmol/exercisedb-api/commit/779c652)) 50 | - **authmiddleware:** Added rbac middleware as global route for preventing post endpoints ([4998a03](https://github.com/cyberboyanmol/exercisedb-api/commit/4998a03)) 51 | 52 | ### 🩹 Fixes 53 | 54 | - **core:** Update autocomplete aggregate query by remove _id ([0710e5b](https://github.com/cyberboyanmol/exercisedb-api/commit/0710e5b)) 55 | 56 | ### ❤️ Contributors 57 | 58 | - Anmol Gangwar 59 | 60 | ## v0.0.2 61 | 62 | 63 | ### 🚀 Enhancements 64 | 65 | - Project structure setup ([4181ff2](https://github.com/cyberboyanmol/exercisedb-api/commit/4181ff2)) 66 | - Project structure setup ([c347665](https://github.com/cyberboyanmol/exercisedb-api/commit/c347665)) 67 | - Setting up redis ([ad45afc](https://github.com/cyberboyanmol/exercisedb-api/commit/ad45afc)) 68 | - **database:** ✨ added mongodb setup and exercises schema ([4fc7b69](https://github.com/cyberboyanmol/exercisedb-api/commit/4fc7b69)) 69 | - **database modelling:** 📦️ add exercise, equipment,bodypart,muscle schemas ([7d07049](https://github.com/cyberboyanmol/exercisedb-api/commit/7d07049)) 70 | - **module:** ✨ added muscles module ([4d0f63e](https://github.com/cyberboyanmol/exercisedb-api/commit/4d0f63e)) 71 | - **modules:** ✨ added equipment module ([7c1cda1](https://github.com/cyberboyanmol/exercisedb-api/commit/7c1cda1)) 72 | - **modules:** ✨ added bodyPart module ([84a556f](https://github.com/cyberboyanmol/exercisedb-api/commit/84a556f)) 73 | - **schema:** 🔨 updated exercise schema ([f13f0fb](https://github.com/cyberboyanmol/exercisedb-api/commit/f13f0fb)) 74 | - 🔨 refactoring code ([22e93e9](https://github.com/cyberboyanmol/exercisedb-api/commit/22e93e9)) 75 | - **modules:** ✨ added exercise module ([df8a2d4](https://github.com/cyberboyanmol/exercisedb-api/commit/df8a2d4)) 76 | - **exercises:** ✨ added retrive all exercises with pagination ([2016e16](https://github.com/cyberboyanmol/exercisedb-api/commit/2016e16)) 77 | - **exercises:** ⚡️ added search query on exercises endpoint ([e639f2c](https://github.com/cyberboyanmol/exercisedb-api/commit/e639f2c)) 78 | - **exercises:** 🔍️ added autocomplete suggestion search ([a08f6d3](https://github.com/cyberboyanmol/exercisedb-api/commit/a08f6d3)) 79 | - **deployment:** Added workflow yaml ([25953ca](https://github.com/cyberboyanmol/exercisedb-api/commit/25953ca)) 80 | - **images:** Added image view and upload endpoint ([9fbac0c](https://github.com/cyberboyanmol/exercisedb-api/commit/9fbac0c)) 81 | 82 | ### ❤️ Contributors 83 | 84 | - Anmol Gangwar 85 | 86 | ## v1.1.0 87 | 88 | 89 | ### 🚀 Enhancements 90 | 91 | - Project structure setup ([4181ff2](https://github.com/cyberboyanmol/exercisedb-api/commit/4181ff2)) 92 | - Project structure setup ([c347665](https://github.com/cyberboyanmol/exercisedb-api/commit/c347665)) 93 | - Setting up redis ([ad45afc](https://github.com/cyberboyanmol/exercisedb-api/commit/ad45afc)) 94 | - **database:** ✨ added mongodb setup and exercises schema ([4fc7b69](https://github.com/cyberboyanmol/exercisedb-api/commit/4fc7b69)) 95 | - **database modelling:** 📦️ add exercise, equipment,bodypart,muscle schemas ([7d07049](https://github.com/cyberboyanmol/exercisedb-api/commit/7d07049)) 96 | - **module:** ✨ added muscles module ([4d0f63e](https://github.com/cyberboyanmol/exercisedb-api/commit/4d0f63e)) 97 | - **modules:** ✨ added equipment module ([7c1cda1](https://github.com/cyberboyanmol/exercisedb-api/commit/7c1cda1)) 98 | - **modules:** ✨ added bodyPart module ([84a556f](https://github.com/cyberboyanmol/exercisedb-api/commit/84a556f)) 99 | - **schema:** 🔨 updated exercise schema ([f13f0fb](https://github.com/cyberboyanmol/exercisedb-api/commit/f13f0fb)) 100 | - 🔨 refactoring code ([22e93e9](https://github.com/cyberboyanmol/exercisedb-api/commit/22e93e9)) 101 | - **modules:** ✨ added exercise module ([df8a2d4](https://github.com/cyberboyanmol/exercisedb-api/commit/df8a2d4)) 102 | - **exercises:** ✨ added retrive all exercises with pagination ([2016e16](https://github.com/cyberboyanmol/exercisedb-api/commit/2016e16)) 103 | - **exercises:** ⚡️ added search query on exercises endpoint ([e639f2c](https://github.com/cyberboyanmol/exercisedb-api/commit/e639f2c)) 104 | - **exercises:** 🔍️ added autocomplete suggestion search ([a08f6d3](https://github.com/cyberboyanmol/exercisedb-api/commit/a08f6d3)) 105 | - **deployment:** Added workflow yaml ([25953ca](https://github.com/cyberboyanmol/exercisedb-api/commit/25953ca)) 106 | - **images:** Added image view and upload endpoint ([9fbac0c](https://github.com/cyberboyanmol/exercisedb-api/commit/9fbac0c)) 107 | 108 | ### ❤️ Contributors 109 | 110 | - Anmol Gangwar 111 | 112 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Anmol Gangwar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # 🚀 New Exercise Dataset 3 | 4 | What's Inside Our New Dataset 5 | - 3,000+ exercises 6 | - High-quality video demonstration. 7 | - High-quality images 8 | - Multilingual supports 9 | - Many More.. 10 | 11 | 12 | Check out the [Pricing Plan](https://dub.sh/JTgJoq2) for detailed information. 13 | For inquiries: 14 | Telegram: [@cyberboyanmol](https://t.me/cyberboyanmol) (fast response) 15 | 16 | ## Note: Enterprise Solutions 17 | If you're building an application and need complete control over the exercise data, we offer offline dataset access instead of API as SaaS. This includes: 18 | 19 | - Full offline access to all 3,000+ exercises 20 | - with montly/annually subscription 21 | - Regular dataset updates 22 | - Custom data formats 23 | - Unlimited usage rights 24 | - Direct technical support 25 | - Integration assistance 26 | 27 | Exercise sample 28 | ```sh 29 | { 30 | "exerciseId": "K6NnTv0", 31 | "name": "Bench Press", 32 | "imageUrl": "Barbell-Bench-Press_Chest.png", 33 | "equipments": ["Barbell"], 34 | "bodyParts": ["Chest"], 35 | "exerciseType": "weight_reps", 36 | "targetMuscles": ["Pectoralis Major Clavicular Head"], 37 | "secondaryMuscles": [ 38 | "Deltoid Anterior", 39 | "Pectoralis Major Clavicular Head", 40 | "Triceps Brachii" 41 | ], 42 | "videoUrl": "Barbell-Bench-Press_Chest_.mp4", 43 | "keywords": [ 44 | "Chest workout with barbell", 45 | "Barbell bench press exercise", 46 | "Strength training for chest", 47 | "Upper body workout with barbell", 48 | "Barbell chest exercises", 49 | "Bench press for chest muscles", 50 | "Building chest muscles with bench press", 51 | "Chest strengthening with barbell", 52 | "Bench press workout routine", 53 | "Barbell exercises for chest muscle growth" 54 | ], 55 | "overview": "The Bench Press is a classic strength training exercise that primarily targets the chest, shoulders, and triceps, contributing to upper body muscle development. It is suitable for anyone, from beginners to professional athletes, looking to improve their upper body strength and muscular endurance. Individuals may want to incorporate bench press into their routine for its effectiveness in enhancing physical performance, promoting bone health, and improving body composition.", 56 | "instructions": [ 57 | "Grip the barbell with your hands slightly wider than shoulder-width apart, palms facing your feet, and lift it off the rack, holding it straight over your chest with your arms fully extended.", 58 | "Slowly lower the barbell down to your chest while keeping your elbows at a 90-degree angle.", 59 | "Once the barbell touches your chest, push it back up to the starting position while keeping your back flat on the bench.", 60 | "Repeat this process for the desired number of repetitions, always maintaining control of the barbell and ensuring your form is correct." 61 | ], 62 | "exerciseTips": [ 63 | "Avoid Arching Your Back: One common mistake is excessively arching the back during the lift. This can lead to lower back injuries. Your lower back should have a natural arch, but it should not be overly exaggerated. Your butt, shoulders, and head should maintain contact with the bench at all times.", 64 | "Controlled Movement: Avoid the temptation to lift the barbell too quickly. A controlled, steady lift is more effective and reduces the risk of injury. Lower the bar to your mid-chest slowly, pause briefly, then push it back up without locking your elbows at the top.", 65 | "Don't Lift Alone:" 66 | ], 67 | "variations": [ 68 | "Decline Bench Press: This variation is performed on a decline bench to target the lower part of the chest.", 69 | "Close-Grip Bench Press: This variation focuses on the triceps and the inner part of the chest by placing the hands closer together on the bar.", 70 | "Dumbbell Bench Press: This variation uses dumbbells instead of a barbell, allowing for a greater range of motion and individual arm movement.", 71 | "Reverse-Grip Bench Press: This variation is performed by flipping your grip so that your palms face towards you, targeting the upper chest and triceps." 72 | ], 73 | "relatedExerciseIds": [ 74 | "U0uPZBq", 75 | "QD32SbB", 76 | "pdm4AfV", 77 | "SebLXCG", 78 | "T3JogV7", 79 | "hiWPEs1", 80 | "Y5ppDdt", 81 | "C8OV7Pv", 82 | "r3tQt3U", 83 | "dCSgT7N" 84 | ] 85 | } 86 | 87 | ``` 88 | Sample Image 89 | 90 | ![Bench Press Exercise](https://ucarecdn.com/c12bb487-7390-4fc7-903c-a1c2298e70ad/K6NnTv0__BarbellBenchPress_Chest.png) 91 | 92 | Sample Video 93 | 94 | https://github.com/user-attachments/assets/6845a963-4d80-4dfd-b602-e49616a9483f 95 | 96 | 97 | # ExerciseDB API 98 | 99 | ![GitHub License](https://img.shields.io/github/license/cyberboyanmol/exercisedb-api) 100 | ![GitHub Release](https://img.shields.io/github/v/release/cyberboyanmol/exercisedb-api) 101 | 102 | ExerciseDB API, accessible at [exercisedb-api.vercel.app](https://exercisedb-api.vercel.app/), is an exercises API that allows users to access high-quality exercises data which consists 1300+ exercises. This API offers extensive information on each exercise, including target body parts, equipment needed, GIFs for visual guidance, and step-by-step instructions. 103 | 104 | ## ⚠️ Important Notice 105 | The unauthorized downloading, scraping, or bulk collection of data from this API is strictly prohibited. This API is intended for individual exercise lookups and legitimate application integration only. If you need access to the complete dataset or have specific requirements, please contact via Telegram at [@cyberboyanmol](https://t.me/cyberboyanmol). We're happy to discuss proper data usage and potential collaborations. 106 | 107 | ## 📚 Documentation 108 | 109 | Check out the [API documentation](https://exercisedb-api.vercel.app/docs) for detailed information on how to use the API. 110 | 111 | ## 📰 Changelog 112 | 113 | For a detailed list of changes, see the [CHANGELOG](CHANGELOG.md). 114 | 115 | ## 🔌 Running Locally 116 | 117 | > [!NOTE] 118 | > You need `Bun(1.1.25+)` or `Node.js(v20+)` 119 | 120 | 1. Clone the repository: 121 | 122 | ```sh 123 | git clone https://github.com/cyberboyanmol/exercisedb-api 124 | cd exercisedb-api 125 | ``` 126 | 127 | 2. Install the required dependencies: 128 | 129 | ```sh 130 | bun install 131 | ``` 132 | 133 | 3. Launch the development server: 134 | 135 | ```sh 136 | bun run dev 137 | ``` 138 | 139 | ## ☁️ Deploying Your Own Instance 140 | 141 | You can easily deploy your own instance of the API by clicking the button below: 142 | 143 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/cyberboyanmol/exercisedb-api) 144 | 145 | ## 📜 License 146 | 147 | This project is distributed under the [MIT License](https://opensource.org/licenses/MIT). For more information, see the issue [ISSUE](https://github.com/cyberboyanmol/exercisedb-api/issues/3) included in this repository. 148 | -------------------------------------------------------------------------------- /api/index.ts: -------------------------------------------------------------------------------- 1 | import { handle } from '@hono/node-server/vercel' 2 | import app from '../dist/server.js' 3 | 4 | export default handle(app) 5 | -------------------------------------------------------------------------------- /assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cyberboyanmol/exercisedb-api/c9c34b7124de25bfe9758f90005c76b513dbe738/assets/.gitkeep -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cyberboyanmol/exercisedb-api/c9c34b7124de25bfe9758f90005c76b513dbe738/bun.lockb -------------------------------------------------------------------------------- /changelog.config.ts: -------------------------------------------------------------------------------- 1 | export default () => ({ 2 | types: { 3 | feat: { title: '🚀 Enhancements', semver: 'minor' }, 4 | perf: { title: '🔥 Performance', semver: 'patch' }, 5 | fix: { title: '🩹 Fixes', semver: 'patch' }, 6 | examples: { title: '🏀 Examples' }, 7 | docs: { title: '📖 Documentation', semver: 'patch' }, 8 | types: { title: '🌊 Types', semver: 'patch' }, 9 | refactor: false, 10 | build: false, 11 | chore: false, 12 | test: false, 13 | style: false, 14 | ci: false 15 | } 16 | }) 17 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { extends: ['@commitlint/config-conventional'] } 2 | -------------------------------------------------------------------------------- /deploy.md: -------------------------------------------------------------------------------- 1 | testing deployment 2 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import globals from 'globals' 2 | import pluginJs from '@eslint/js' 3 | import tseslint from 'typescript-eslint' 4 | 5 | export default [ 6 | ...tseslint.configs.recommended, 7 | pluginJs.configs.recommended, 8 | { 9 | ignores: ['dist/*', 'seed/*'] 10 | }, 11 | { 12 | files: ['src/**/*.ts'], 13 | languageOptions: { globals: globals.node }, 14 | rules: { 15 | 'prefer-const': 'warn', 16 | '@typescript-eslint/no-explicit-any': 'warn', 17 | 'no-unused-vars': 'warn', 18 | '@typescript-eslint/no-unused-vars': 'warn' 19 | } 20 | } 21 | ] 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "exercisedb-api", 3 | "description": "Free ExerciseDB API", 4 | "repository": "https://github.com/cyberboyanmol/exercisedb-api", 5 | "version": "0.0.7", 6 | "author": "Anmol Gangwar", 7 | "license": "MIT", 8 | "type": "module", 9 | "main": "dist/server.js", 10 | "module": "dist/server.js", 11 | "types": "dist/index.d.ts", 12 | "exports": { 13 | "require": "./dist/index.js", 14 | "import": "./dist/index.js" 15 | }, 16 | "sideEffects": false, 17 | "imports": { 18 | "#modules/*": { 19 | "types": "./src/modules/*/index.d.ts", 20 | "production": "./dist/modules/*/index.js", 21 | "default": "./src/modules/*/" 22 | }, 23 | "#common/*": { 24 | "types": "./src/common/*/index.d.ts", 25 | "production": "./dist/common/*/index.js", 26 | "default": "./src/common/*/" 27 | }, 28 | "#infra/*": { 29 | "types": "./src/infra/*/index.d.ts", 30 | "production": "./dist/infra/*/index.js", 31 | "default": "./src/infra/*/" 32 | } 33 | }, 34 | "scripts": { 35 | "dev": "bun run --hot src/server.ts", 36 | "start": "bun dist/server.js", 37 | "build": "tsc && tsc-alias", 38 | "format": "prettier --write \"./**/*.{js,ts,json}\"", 39 | "lint": "eslint .", 40 | "lint:fix": "bun run lint --fix", 41 | "test": "vitest run", 42 | "test:ui": "vitest --ui", 43 | "deploy": "vercel deploy --prod", 44 | "release": "bun run test && bun run changelogen --release --push", 45 | "postinstall": "npx simple-git-hooks" 46 | }, 47 | "simple-git-hooks": { 48 | "pre-commit": "bun run lint && bun run format", 49 | "commit-msg": "bun run commitlint --edit $1" 50 | }, 51 | "dependencies": { 52 | "@hono/node-server": "^1.12.1", 53 | "@hono/zod-openapi": "^0.15.3", 54 | "@scalar/hono-api-reference": "^0.5.142", 55 | "@supabase/supabase-js": "^2.45.2", 56 | "@typescript-eslint/eslint-plugin": "^8.2.0", 57 | "axios": "^1.7.5", 58 | "form-data": "^4.0.0", 59 | "hono": "^4.5.8", 60 | "ioredis": "^5.4.1", 61 | "jsonwebtoken": "^9.0.2", 62 | "mongoose": "^8.5.3", 63 | "otplib": "^12.0.1", 64 | "zod": "^3.23.8" 65 | }, 66 | "devDependencies": { 67 | "@commitlint/cli": "^19.4.0", 68 | "@commitlint/config-conventional": "^19.2.2", 69 | "@eslint/js": "^9.9.0", 70 | "@types/bun": "latest", 71 | "@types/ioredis": "^5.0.0", 72 | "@types/jsonwebtoken": "^9.0.6", 73 | "@types/mongoose": "^5.11.97", 74 | "@types/node": "^22.5.0", 75 | "@typescript-eslint/parser": "^8.2.0", 76 | "@vitest/ui": "^2.0.5", 77 | "bun-types": "^1.1.25", 78 | "changelogen": "^0.5.5", 79 | "eslint": "^9.9.0", 80 | "eslint-config-prettier": "^9.1.0", 81 | "eslint-plugin-prettier": "^5.2.1", 82 | "globals": "^15.9.0", 83 | "prettier": "^3.3.3", 84 | "simple-git-hooks": "^2.11.1", 85 | "tsc-alias": "^1.8.10", 86 | "typescript": "^5.5.4", 87 | "typescript-eslint": "^8.2.0", 88 | "vitest": "^2.0.5" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | semi: false, 3 | trailingComma: 'none', 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 2, 7 | endOfLine: 'auto' 8 | } 9 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIHono } from '@hono/zod-openapi' 2 | import { apiReference } from '@scalar/hono-api-reference' 3 | import { logger } from 'hono/logger' 4 | import { prettyJSON } from 'hono/pretty-json' 5 | import { Home } from './pages/home' 6 | import { Routes } from '#common/types' 7 | import type { HTTPException } from 'hono/http-exception' 8 | import { DalService } from './infra/mongodb/dal.service' 9 | import { cors } from 'hono/cors' 10 | import { authMiddleware } from './middleware/auth' 11 | export class App { 12 | private app: OpenAPIHono 13 | private dalService: DalService 14 | constructor(routes: Routes[]) { 15 | this.app = new OpenAPIHono() 16 | this.dalService = DalService.getInstance() 17 | this.initializeApp(routes) 18 | } 19 | private async initializeApp(routes: Routes[]) { 20 | try { 21 | this.initializeGlobalMiddleware() 22 | this.initializeRoutes(routes) 23 | this.initializeSwaggerUI() 24 | this.initializeRouteFallback() 25 | this.initializeErrorHandler() 26 | } catch (error) { 27 | console.error('Failed to initialize application:', error) 28 | throw new Error('Failed to initialize application') 29 | } 30 | } 31 | 32 | private initializeRoutes(routes: Routes[]) { 33 | routes.forEach((route) => { 34 | route.initRoutes() 35 | this.app.route('/api/v1', route.controller) 36 | }) 37 | this.app.route('/', Home) 38 | } 39 | 40 | private initializeGlobalMiddleware() { 41 | this.app.use(async (c, next) => { 42 | try { 43 | await this.dalService.connectDB() 44 | await next() 45 | } catch (error) { 46 | console.error('Database connection error:', error) 47 | return c.json({ error: 'Internal Server Error' }, 500) 48 | } finally { 49 | // await this.dalService.cleanup() 50 | } 51 | }) 52 | this.app.use( 53 | cors({ 54 | origin: '*', 55 | allowMethods: ['GET', 'POST', 'OPTIONS'] 56 | }) 57 | ) 58 | 59 | this.app.use(logger()) 60 | this.app.use(prettyJSON()) 61 | this.app.use(async (c, next) => { 62 | const start = Date.now() 63 | await next() 64 | const end = Date.now() 65 | c.res.headers.set('X-Response-Time', `${end - start}ms`) 66 | }) 67 | 68 | this.app.use(authMiddleware) 69 | } 70 | 71 | private initializeSwaggerUI() { 72 | this.app.doc31('/swagger', (c) => { 73 | const { protocol: urlProtocol, hostname, port } = new URL(c.req.url) 74 | const protocol = c.req.header('x-forwarded-proto') ? `${c.req.header('x-forwarded-proto')}:` : urlProtocol 75 | 76 | return { 77 | openapi: '3.1.0', 78 | info: { 79 | version: '1.0.0', 80 | title: 'ExerciseDB API', 81 | description: `# Introduction 82 | \nExerciseDB API, accessible at [exercisedb-api.vercel.app](https://exercisedb-api.vercel.app), is an exercises API that allows users to access high-quality exercises data which consists 1300+ exercises. 83 | This API offers extensive information on each exercise, including target body parts, equipment needed, GIFs for visual guidance, and step-by-step instructions.\n` 84 | }, 85 | 86 | servers: [ 87 | { 88 | url: `${protocol}//${hostname}${port ? `:${port}` : ''}`, 89 | description: 'Current environment' 90 | } 91 | ] 92 | } 93 | }) 94 | 95 | this.app.get( 96 | '/docs', 97 | apiReference({ 98 | pageTitle: 'ExerciseDB API Documentation', 99 | theme: 'bluePlanet', 100 | isEditable: false, 101 | layout: 'modern', 102 | darkMode: true, 103 | metaData: { 104 | applicationName: 'ExerciseDB API', 105 | author: 'Anmol Gangwar', 106 | creator: 'Anmol Gangwar', 107 | publisher: 'Anmol Gangwar', 108 | robots: 'index follow', 109 | description: 110 | 'Access detailed data on over 1300+ exercises with the ExerciseDB API. This API offers extensive information on each exercise, including target body parts, equipment needed, GIFs for visual guidance, and step-by-step instructions.' 111 | }, 112 | 113 | spec: { 114 | url: '/swagger' 115 | } 116 | }) 117 | ) 118 | } 119 | 120 | private initializeRouteFallback() { 121 | this.app.notFound((c) => { 122 | return c.json( 123 | { 124 | success: false, 125 | message: 'oops route not found!!. check docs at https://exercisedb-api.vercel.app/docs' 126 | }, 127 | 404 128 | ) 129 | }) 130 | } 131 | private initializeErrorHandler() { 132 | this.app.onError((err, c) => { 133 | const error = err as HTTPException 134 | console.log(error) 135 | return c.json({ success: false, message: error.message }, error.status || 500) 136 | }) 137 | } 138 | public getApp() { 139 | return this.app 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/common/helpers/crypto.service.ts: -------------------------------------------------------------------------------- 1 | import { IUserDoc } from '#infra/mongodb/models/users/user.entity.js' 2 | import jwt from 'jsonwebtoken' 3 | import { authenticator } from 'otplib' 4 | export type JwtPayloadInterface = Pick 5 | 6 | export class CryptoService { 7 | public generateOtpSecret() { 8 | const otpSecret = authenticator.generateSecret() 9 | return otpSecret 10 | } 11 | public verifyCode(otpSecret: string, code: string) { 12 | return authenticator.verify({ 13 | secret: otpSecret, 14 | token: code 15 | }) 16 | } 17 | public generateAccessToken(user: JwtPayloadInterface) { 18 | const options: jwt.SignOptions = { 19 | algorithm: 'HS256', 20 | expiresIn: process.env.JWT_ACCESS_TOKEN_EXPIRATION, 21 | issuer: 'ExerciseDB', 22 | audience: `user_${user.id}`, 23 | subject: 'accessToken' 24 | } 25 | const payload: JwtPayloadInterface = { 26 | id: user.id, 27 | role: user.role, 28 | isActivated: user.isActivated 29 | } 30 | const accessToken = jwt.sign(payload, process.env.JWT_ACCESS_TOKEN_SECRET!, options) 31 | return accessToken 32 | } 33 | 34 | public verifyAccessToken(token: string) { 35 | const options: jwt.SignOptions = { 36 | algorithm: 'HS256', 37 | issuer: 'ExerciseDB' 38 | } 39 | const decoded = jwt.verify(token, process.env.JWT_ACCESS_TOKEN_SECRET!, options) as JwtPayloadInterface 40 | return decoded 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/common/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './route.type' 2 | -------------------------------------------------------------------------------- /src/common/types/redis.interface.ts: -------------------------------------------------------------------------------- 1 | export interface RedisRepositoryInterface { 2 | get(prefix: string, key: string): Promise 3 | set(prefix: string, key: string, value: string): Promise 4 | delete(prefix: string, key: string): Promise 5 | setWithExpiry(prefix: string, key: string, value: string, expiry: number): Promise 6 | } 7 | -------------------------------------------------------------------------------- /src/common/types/route.type.ts: -------------------------------------------------------------------------------- 1 | import type { OpenAPIHono } from '@hono/zod-openapi' 2 | 3 | export interface Routes { 4 | controller: OpenAPIHono 5 | initRoutes: () => void 6 | } 7 | -------------------------------------------------------------------------------- /src/common/types/use-case.type.ts: -------------------------------------------------------------------------------- 1 | interface Obj { 2 | [key: string]: any 3 | } 4 | 5 | export interface IUseCase { 6 | execute: (params: T) => Promise 7 | } 8 | -------------------------------------------------------------------------------- /src/infra/mongodb/dal.service.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Connection } from 'mongoose' 2 | 3 | let cachedConnection: Connection | null = null 4 | let connectionPromise: Promise | null = null 5 | 6 | export class DalService { 7 | private static instance: DalService 8 | 9 | private constructor() {} 10 | 11 | static getInstance(): DalService { 12 | if (!DalService.instance) { 13 | DalService.instance = new DalService() 14 | } 15 | return DalService.instance 16 | } 17 | 18 | async connectDB(): Promise { 19 | if (cachedConnection && this.isConnected()) { 20 | return cachedConnection 21 | } 22 | 23 | if (!process.env.EXERCISEDB_DATABASE) { 24 | throw new Error('EXERCISEDB_DATABASE environment variable is not set') 25 | } 26 | 27 | if (!connectionPromise) { 28 | connectionPromise = mongoose 29 | .connect(process.env.EXERCISEDB_DATABASE, { 30 | serverSelectionTimeoutMS: 5000, 31 | socketTimeoutMS: 45000, 32 | maxPoolSize: 1, 33 | minPoolSize: 0, 34 | maxIdleTimeMS: 10000 35 | }) 36 | .then((conn) => { 37 | console.log('Database connected successfully') 38 | return conn.connection 39 | }) 40 | .catch((error) => { 41 | console.error('Error connecting to the database:', error) 42 | connectionPromise = null 43 | throw error 44 | }) 45 | } 46 | 47 | try { 48 | cachedConnection = await connectionPromise 49 | return cachedConnection 50 | } catch (error) { 51 | throw new Error('Error connecting to the database') 52 | } 53 | } 54 | 55 | isConnected(): boolean { 56 | return cachedConnection?.readyState === 1 57 | } 58 | 59 | async disconnect(): Promise { 60 | if (cachedConnection) { 61 | try { 62 | await mongoose.disconnect() 63 | cachedConnection = null 64 | connectionPromise = null 65 | console.log('Database disconnected successfully') 66 | } catch (error) { 67 | console.error('Error disconnecting from the database:', error) 68 | throw error 69 | } 70 | } 71 | } 72 | 73 | async destroy(): Promise { 74 | if (process.env.NODE_ENV !== 'test') { 75 | throw new Error('Allowed only in test environment') 76 | } 77 | 78 | try { 79 | if (cachedConnection) { 80 | await cachedConnection.dropDatabase() 81 | console.log('Database dropped successfully') 82 | } 83 | } catch (error) { 84 | console.error('Error dropping the database:', error) 85 | throw error 86 | } finally { 87 | await this.disconnect() 88 | } 89 | } 90 | async closeConnection(): Promise { 91 | if (cachedConnection) { 92 | await cachedConnection.close() 93 | cachedConnection = null 94 | connectionPromise = null 95 | console.log('Database connection closed') 96 | } 97 | } 98 | 99 | async cleanup(): Promise { 100 | await this.closeConnection() 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/infra/mongodb/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cyberboyanmol/exercisedb-api/c9c34b7124de25bfe9758f90005c76b513dbe738/src/infra/mongodb/index.ts -------------------------------------------------------------------------------- /src/infra/mongodb/models/bodyparts/bodypart.entity.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Model } from 'mongoose' 2 | 3 | export interface IBodyPart { 4 | name: string 5 | } 6 | export interface IBodyPartDoc extends IBodyPart, Document {} 7 | export interface IBodyPartModel extends Model { 8 | isBodyPartExist(name: string, excludeBodyPartId?: mongoose.ObjectId): Promise 9 | } 10 | -------------------------------------------------------------------------------- /src/infra/mongodb/models/bodyparts/bodypart.schema.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import { IBodyPartDoc, IBodyPartModel } from './bodypart.entity' 3 | import toJSONWithoutId from '#infra/mongodb/plugins/toJSONWithoutId/toJSONWithoutId.js' 4 | const bodyPartSchema = new mongoose.Schema( 5 | { 6 | name: { 7 | type: String, 8 | required: true, 9 | trim: true, 10 | unique: true, 11 | index: true, 12 | lowercase: true 13 | } 14 | }, 15 | { 16 | timestamps: true 17 | } 18 | ) 19 | 20 | // add plugin that converts mongoose to json 21 | bodyPartSchema.plugin(toJSONWithoutId) 22 | 23 | /** 24 | * check if the similar bodyPart name already exists 25 | * @param {string} name 26 | *@returns {Promise} 27 | */ 28 | 29 | bodyPartSchema.static( 30 | 'isBodyPartExist', 31 | async function (name: string, excludeBodyPartId: mongoose.ObjectId): Promise { 32 | const bodyPart = await this.findOne({ 33 | name, 34 | _id: { $ne: excludeBodyPartId } 35 | }) 36 | return !!bodyPart 37 | } 38 | ) 39 | const BodyPart = mongoose.model('BodyPart', bodyPartSchema) 40 | 41 | export default BodyPart 42 | -------------------------------------------------------------------------------- /src/infra/mongodb/models/equipments/equipment.entity.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Model } from 'mongoose' 2 | 3 | export interface IEquipment { 4 | name: string 5 | } 6 | export interface IEquipmentDoc extends IEquipment, Document {} 7 | export interface IEquipmentModel extends Model { 8 | isEquipmentExist(name: string, excludeEquipmentId?: mongoose.ObjectId): Promise 9 | } 10 | -------------------------------------------------------------------------------- /src/infra/mongodb/models/equipments/equipment.schema.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import { IEquipmentDoc, IEquipmentModel } from './equipment.entity' 3 | import toJSONWithoutId from '#infra/mongodb/plugins/toJSONWithoutId/toJSONWithoutId.js' 4 | 5 | const equipmentSchema = new mongoose.Schema( 6 | { 7 | name: { 8 | type: String, 9 | required: true, 10 | trim: true, 11 | unique: true, 12 | index: true, 13 | lowercase: true 14 | } 15 | }, 16 | { 17 | timestamps: true 18 | } 19 | ) 20 | 21 | // add plugin that converts mongoose to json 22 | equipmentSchema.plugin(toJSONWithoutId) 23 | 24 | /** 25 | * check if the similar equipment name already exists 26 | * @param {string} name 27 | *@returns {Promise} 28 | */ 29 | 30 | equipmentSchema.static( 31 | 'isEquipmentExist', 32 | async function (name: string, excludeEquipmentId: mongoose.ObjectId): Promise { 33 | const equipment = await this.findOne({ 34 | name, 35 | _id: { $ne: excludeEquipmentId } 36 | }) 37 | return !!equipment 38 | } 39 | ) 40 | const Equipment = mongoose.model('Equipment', equipmentSchema) 41 | 42 | export default Equipment 43 | -------------------------------------------------------------------------------- /src/infra/mongodb/models/exercises/exercise.entity.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Model } from 'mongoose' 2 | 3 | export interface IExercise { 4 | exerciseId: string 5 | name: string 6 | gifUrl: string 7 | instructions: string[] 8 | targetMuscles: string[] 9 | bodyParts: string[] 10 | equipments: string[] 11 | secondaryMuscles: string[] 12 | } 13 | export type UpdateExerciseBody = Partial 14 | export interface IExerciseDoc extends IExercise, Document {} 15 | export interface IExerciseModel extends Model { 16 | isExerciseExist(exerciseId: string, excludeExerciseId?: mongoose.ObjectId): Promise 17 | } 18 | -------------------------------------------------------------------------------- /src/infra/mongodb/models/exercises/exercise.schema.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema } from 'mongoose' 2 | import { IExerciseDoc, IExerciseModel } from './exercise.entity' 3 | import toJSONWithoutId from '#infra/mongodb/plugins/toJSONWithoutId/toJSONWithoutId.js' 4 | const exerciseSchema = new mongoose.Schema( 5 | { 6 | exerciseId: { 7 | type: String, 8 | trim: true, 9 | required: true, 10 | unique: true, 11 | index: true 12 | }, 13 | name: { 14 | type: String, 15 | required: true, 16 | trim: true, 17 | lowercase: true 18 | }, 19 | gifUrl: { 20 | type: String, 21 | required: true, 22 | trim: true 23 | }, 24 | instructions: [ 25 | { 26 | _id: false, 27 | type: String, 28 | trim: true 29 | } 30 | ], 31 | targetMuscles: [ 32 | { 33 | type: String 34 | } 35 | ], 36 | bodyParts: [ 37 | { 38 | type: String 39 | } 40 | ], 41 | equipments: [ 42 | { 43 | type: String 44 | } 45 | ], 46 | secondaryMuscles: [ 47 | { 48 | type: String 49 | } 50 | ] 51 | }, 52 | { 53 | timestamps: true 54 | } 55 | ) 56 | 57 | exerciseSchema.plugin(toJSONWithoutId) 58 | exerciseSchema.index({ 59 | name: 'text', 60 | targetMuscles: 'text', 61 | bodyParts: 'text', 62 | equipments: 'text', 63 | secondaryMuscles: 'text' 64 | }) 65 | /** 66 | * check if the similar equipment name already exists 67 | * @param {string} name 68 | *@returns {Promise} 69 | */ 70 | 71 | exerciseSchema.static( 72 | 'isExerciseExist', 73 | async function (exerciseId: string, excludeExerciseId: mongoose.ObjectId): Promise { 74 | const exercise = await this.findOne({ 75 | exerciseId, 76 | _id: { $ne: excludeExerciseId } 77 | }) 78 | return !!exercise 79 | } 80 | ) 81 | const Exercise = mongoose.model('Exercise', exerciseSchema) 82 | 83 | export default Exercise 84 | -------------------------------------------------------------------------------- /src/infra/mongodb/models/muscles/muscle.entity.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Model } from 'mongoose' 2 | export interface IMuscle { 3 | name: string 4 | } 5 | export interface IMuscleDoc extends IMuscle, Document {} 6 | export interface IMuscleModel extends Model { 7 | isMuscleExist(name: string, excludeMuscleId?: mongoose.ObjectId): Promise 8 | } 9 | -------------------------------------------------------------------------------- /src/infra/mongodb/models/muscles/muscle.schema.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import { IMuscleDoc, IMuscleModel } from './muscle.entity' 3 | import toJSONWithoutId from '#infra/mongodb/plugins/toJSONWithoutId/toJSONWithoutId.js' 4 | 5 | const muscleSchema = new mongoose.Schema( 6 | { 7 | name: { 8 | type: String, 9 | required: true, 10 | trim: true, 11 | unique: true, 12 | index: true, 13 | lowercase: true 14 | } 15 | }, 16 | { 17 | timestamps: true 18 | } 19 | ) 20 | // add plugin that converts mongoose to json 21 | muscleSchema.plugin(toJSONWithoutId) 22 | 23 | /** 24 | * check if the similar muscle name already exists 25 | * @param {string} name 26 | *@returns {Promise} 27 | */ 28 | 29 | muscleSchema.static( 30 | 'isMuscleExist', 31 | async function (name: string, excludeMuscleId: mongoose.ObjectId): Promise { 32 | const muscle = await this.findOne({ 33 | name, 34 | _id: { $ne: excludeMuscleId } 35 | }) 36 | return !!muscle 37 | } 38 | ) 39 | 40 | const Muscle = mongoose.model('Muscle', muscleSchema) 41 | 42 | export default Muscle 43 | -------------------------------------------------------------------------------- /src/infra/mongodb/models/users/user.entity.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Model } from 'mongoose' 2 | export interface IUser { 3 | email: string 4 | role: string 5 | isActivated: boolean 6 | otpSecret: string 7 | } 8 | export interface IUserDoc extends IUser, Document {} 9 | export interface IUserModel extends Model { 10 | isEmailExist(email: string, excludeMuscleId?: mongoose.ObjectId): Promise 11 | } 12 | -------------------------------------------------------------------------------- /src/infra/mongodb/models/users/user.schema.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import { IUserDoc, IUserModel } from './user.entity' 3 | import toJSON from '#infra/mongodb/plugins/toJSON/toJSON.js' 4 | 5 | const userSchema = new mongoose.Schema( 6 | { 7 | email: { 8 | type: String, 9 | unique: true, 10 | trim: true, 11 | lowercase: true, 12 | validate(value: string) { 13 | const EmailRegex = /\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*/ 14 | if (!EmailRegex.test(value)) { 15 | throw new Error('Invalid email.') 16 | } 17 | } 18 | }, 19 | role: { 20 | type: String, 21 | enum: ['member', 'admin'], 22 | required: true, 23 | default: 'member' 24 | }, 25 | isActivated: { 26 | type: Boolean, 27 | required: true, 28 | default: false 29 | }, 30 | otpSecret: { 31 | type: String, 32 | required: true, 33 | unique: true 34 | } 35 | }, 36 | { 37 | timestamps: true 38 | } 39 | ) 40 | // add plugin that converts mongoose to json 41 | userSchema.plugin(toJSON) 42 | 43 | /** 44 | * check if the similar muscle name already exists 45 | * @param {string} name 46 | *@returns {Promise} 47 | */ 48 | 49 | userSchema.static('isEmailExist', async function (email: string, excludeUserId: mongoose.ObjectId): Promise { 50 | const user = await this.findOne({ 51 | email, 52 | _id: { $ne: excludeUserId } 53 | }) 54 | return !!user 55 | }) 56 | 57 | const User = mongoose.model('User', userSchema) 58 | 59 | export default User 60 | -------------------------------------------------------------------------------- /src/infra/mongodb/plugins/index.ts: -------------------------------------------------------------------------------- 1 | export * from './toJSON/toJSON' 2 | -------------------------------------------------------------------------------- /src/infra/mongodb/plugins/toJSON/toJSON.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'mongoose' 2 | 3 | /** 4 | * A mongoose schema plugin which applies the following in the toJSON transform call: 5 | * - removes __v, createdAt, updatedAt, and any path that has private: true 6 | * - replaces _id with id 7 | */ 8 | 9 | const deleteAtPath = (obj: any, path: any, index: number) => { 10 | if (index === path.length - 1) { 11 | delete obj[path[index]] 12 | return 13 | } 14 | deleteAtPath(obj[path[index]], path, index + 1) 15 | } 16 | 17 | const toJSON = (schema: any) => { 18 | // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 19 | let transform: Function 20 | if (schema.options.toJSON && schema.options.toJSON.transform) { 21 | transform = schema.options.toJSON.transform 22 | } 23 | 24 | schema.options.toJSON = Object.assign(schema.options.toJSON || {}, { 25 | transform(doc: Document, ret: any, options: Record) { 26 | Object.keys(schema.paths).forEach((path) => { 27 | if (schema.paths[path].options && schema.paths[path].options.private) { 28 | deleteAtPath(ret, path.split('.'), 0) 29 | } 30 | }) 31 | 32 | ret.id = ret._id.toString() 33 | delete ret._id 34 | delete ret.__v 35 | delete ret.createdAt 36 | delete ret.updatedAt 37 | if (transform) { 38 | return transform(doc, ret, options) 39 | } 40 | } 41 | }) 42 | } 43 | 44 | export default toJSON 45 | -------------------------------------------------------------------------------- /src/infra/mongodb/plugins/toJSONWithoutId/toJSONWithoutId.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'mongoose' 2 | 3 | /** 4 | * A mongoose schema plugin which applies the following in the toJSON transform call: 5 | * - removes __v, createdAt, updatedAt, and any path that has private: true 6 | * - replaces _id with id 7 | */ 8 | 9 | const deleteAtPath = (obj: any, path: any, index: number) => { 10 | if (index === path.length - 1) { 11 | delete obj[path[index]] 12 | return 13 | } 14 | deleteAtPath(obj[path[index]], path, index + 1) 15 | } 16 | 17 | const toJSONWithoutId = (schema: any) => { 18 | // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type 19 | let transform: Function 20 | if (schema.options.toJSON && schema.options.toJSON.transform) { 21 | transform = schema.options.toJSON.transform 22 | } 23 | 24 | schema.options.toJSON = Object.assign(schema.options.toJSON || {}, { 25 | transform(doc: Document, ret: any, options: Record) { 26 | Object.keys(schema.paths).forEach((path) => { 27 | if (schema.paths[path].options && schema.paths[path].options.private) { 28 | deleteAtPath(ret, path.split('.'), 0) 29 | } 30 | }) 31 | 32 | delete ret._id 33 | delete ret.__v 34 | delete ret.createdAt 35 | delete ret.updatedAt 36 | if (transform) { 37 | return transform(doc, ret, options) 38 | } 39 | } 40 | }) 41 | } 42 | 43 | export default toJSONWithoutId 44 | -------------------------------------------------------------------------------- /src/infra/redis/dal.redis.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis' 2 | 3 | export class RedisClient { 4 | private static instances: Map = new Map() 5 | 6 | private constructor() {} 7 | 8 | public static getInstance(url: string, instanceName: string): Redis { 9 | if (!RedisClient.instances.has(url)) { 10 | const client = new Redis(url, { 11 | reconnectOnError: (err) => { 12 | console.error(`Redis ${instanceName} client reconnectOnError: ${err.message}`) 13 | return true 14 | }, 15 | retryStrategy: (times) => { 16 | console.info(`Redis ${instanceName} client retryStrategy called ${times} times`) 17 | if (times >= 20) { 18 | console.error(`Redis ${instanceName} client: Maximum retry attempts reached`) 19 | return undefined // Stop retrying after 20 attempts 20 | } 21 | return Math.min(times * 100, 2000) // Delay between retries 22 | } 23 | }) 24 | 25 | client.on('connect', () => { 26 | console.info(`Redis ${instanceName} client connected`) 27 | }) 28 | 29 | client.on('ready', () => { 30 | console.info(`Redis ${instanceName} client ready to use`) 31 | }) 32 | 33 | client.on('error', (err) => { 34 | console.error(`Redis ${instanceName} client error: ${err.message}`) 35 | }) 36 | 37 | client.on('end', () => { 38 | console.info(`Redis ${instanceName} client disconnected`) 39 | }) 40 | 41 | RedisClient.instances.set(url, client) 42 | } 43 | 44 | return RedisClient.instances.get(url) as Redis 45 | } 46 | 47 | public static quitAll(): void { 48 | RedisClient.instances.forEach((client, url) => { 49 | client.quit() 50 | console.info(`Redis client for ${url} quit`) 51 | }) 52 | } 53 | } 54 | 55 | process.on('SIGINT', () => { 56 | RedisClient.quitAll() 57 | }) 58 | -------------------------------------------------------------------------------- /src/infra/redis/redis.client.ts: -------------------------------------------------------------------------------- 1 | import { RedisClient } from './dal.redis' 2 | 3 | const redisStorageCache = RedisClient.getInstance(process.env.EXERCISEDB_STORAGE_CACHE!, 'EXERCISEDB_STORAGE_CACHE') 4 | 5 | export { redisStorageCache } 6 | -------------------------------------------------------------------------------- /src/infra/redis/redis.service.ts: -------------------------------------------------------------------------------- 1 | import { RedisRepositoryInterface } from '#common/types/redis.interface.js' 2 | import { Redis } from 'ioredis' 3 | 4 | export class RedisService implements RedisRepositoryInterface { 5 | constructor(private readonly redisClient: Redis) {} 6 | 7 | async get(prefix: string, key: string): Promise { 8 | const value = await this.redisClient.get(`${prefix}:${key}`) 9 | return value ? JSON.parse(value) : null 10 | } 11 | 12 | async set(prefix: string, key: string, value: any): Promise { 13 | await this.redisClient.set(`${prefix}:${key}`, JSON.stringify(value)) 14 | } 15 | 16 | async delete(prefix: string, key: string): Promise { 17 | await this.redisClient.del(`${prefix}:${key}`) 18 | } 19 | 20 | async setWithExpiry(prefix: string, key: string, value: any, expiry: number): Promise { 21 | await this.redisClient.set(`${prefix}:${key}`, JSON.stringify(value), 'EX', expiry) 22 | } 23 | async getBuffer(prefix: string, key: string): Promise { 24 | const value = await this.redisClient.getBuffer(`${prefix}:${key}`) 25 | return value || null 26 | } 27 | 28 | async setBuffer(prefix: string, key: string, value: Buffer): Promise { 29 | await this.redisClient.set(`${prefix}:${key}`, value) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/infra/supabase/index.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '@supabase/supabase-js' 2 | 3 | const supabaseUrl = process.env.SUPABASE_PROJECT_URL as string 4 | const supabaseKey = process.env.SUPABASE_ANON_KEY as string 5 | 6 | export const supabase = createClient(supabaseUrl, supabaseKey) 7 | -------------------------------------------------------------------------------- /src/middleware/auth/index.ts: -------------------------------------------------------------------------------- 1 | import { CryptoService } from '#common/helpers/crypto.service.js' 2 | import { Context, Next } from 'hono' 3 | 4 | type Role = 'member' | 'moderator' | 'admin' 5 | 6 | const ROLE_PERMISSIONS: Record> = { 7 | member: new Set(['GET']), 8 | moderator: new Set(['GET', 'POST', 'PUT', 'PATCH']), 9 | admin: new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']) 10 | } 11 | 12 | const RESTRICTED_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']) 13 | const VALID_ROLES = new Set(['member', 'moderator', 'admin']) 14 | 15 | const cryptoService = new CryptoService() 16 | 17 | interface RouteConfig { 18 | path: string 19 | allowedEnvs: Set 20 | skipAuth?: boolean 21 | message?: string 22 | } 23 | 24 | const ROUTE_CONFIG: RouteConfig[] = [ 25 | { 26 | path: '/api/v1/register', 27 | allowedEnvs: new Set(['development', 'staging']), 28 | skipAuth: true, 29 | message: 'signup is not allowed in production' 30 | }, 31 | { path: '/api/v1/authenticate', allowedEnvs: new Set(['development', 'staging', 'production']), skipAuth: true } 32 | ] 33 | 34 | export async function authMiddleware(c: Context, next: Next) { 35 | const { method, path } = c.req 36 | const currentEnv = process.env.NODE_ENV || 'development' 37 | 38 | const routeConfig = ROUTE_CONFIG.find((route) => route.path === path) 39 | 40 | if (routeConfig) { 41 | if (!routeConfig.allowedEnvs.has(currentEnv)) { 42 | return c.json( 43 | { 44 | success: false, 45 | error: routeConfig.message 46 | ? routeConfig.message 47 | : `This route is not available in the ${currentEnv} environment.` 48 | }, 49 | 403 50 | ) 51 | } 52 | if (routeConfig.skipAuth) { 53 | return next() 54 | } 55 | } 56 | 57 | if (!RESTRICTED_METHODS.has(method)) return next() 58 | 59 | const authorization = c.req.header('Authorization') 60 | 61 | if (!authorization) { 62 | return c.json({ success: false, error: 'No authorization header | unauthorized request' }, 401) 63 | } 64 | const token = authorization.split(' ')[1] 65 | 66 | if (!authorization.startsWith('Bearer')) { 67 | return c.json({ success: false, error: 'Invalid authorization header format. Format is "Bearer ".' }, 401) 68 | } 69 | 70 | try { 71 | const userPayload = cryptoService.verifyAccessToken(token) 72 | if (!VALID_ROLES.has(userPayload.role) || !ROLE_PERMISSIONS[userPayload.role as Role].has(method)) { 73 | return c.json({ success: false, error: 'Forbidden' }, 403) 74 | } 75 | 76 | c.set('user', userPayload) 77 | await next() 78 | } catch (error) { 79 | console.error(error) 80 | return c.json({ success: false, error: 'Invalid token' }, 401) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/modules/bodyparts/controllers/bodyPart.controller.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '#common/types/route.type.js' 2 | import { createRoute, OpenAPIHono } from '@hono/zod-openapi' 3 | import { z } from 'zod' 4 | import { BodyPartService } from '../services' 5 | import BodyPart from '#infra/mongodb/models/bodyparts/bodypart.schema.js' 6 | import { BodyPartModel } from '../models/bodyPart.model' 7 | import { HTTPException } from 'hono/http-exception' 8 | import { ExerciseModel } from '#modules/exercises/models/exercise.model.js' 9 | import Exercise from '#infra/mongodb/models/exercises/exercise.schema.js' 10 | 11 | export class BodyPartController implements Routes { 12 | public controller: OpenAPIHono 13 | private readonly bodyPartService: BodyPartService 14 | constructor() { 15 | this.controller = new OpenAPIHono() 16 | this.bodyPartService = new BodyPartService(BodyPart, Exercise) 17 | } 18 | 19 | public initRoutes() { 20 | this.controller.openapi( 21 | createRoute({ 22 | method: 'post', 23 | path: '/bodyparts', 24 | tags: ['BodyParts'], 25 | summary: 'Add a new bodyPart to the database', 26 | description: 'This route is used to add a new bodyPart name to the database.', 27 | operationId: 'createbodyPart', 28 | request: { 29 | body: { 30 | content: { 31 | 'application/json': { 32 | schema: z.object({ 33 | name: z.string().openapi({ 34 | title: 'BodyPart Name', 35 | description: 'The name of the bodyPart to be added', 36 | type: 'string', 37 | example: 'waist' 38 | }) 39 | }) 40 | } 41 | } 42 | } 43 | }, 44 | responses: { 45 | 201: { 46 | description: 'bodyPart successfully added to the database', 47 | content: { 48 | 'application/json': { 49 | schema: z.object({ 50 | success: z.boolean().openapi({ 51 | description: 'Indicates whether the request was successful', 52 | type: 'boolean', 53 | example: true 54 | }), 55 | data: z.array(BodyPartModel).openapi({ 56 | title: 'Added bodyPart', 57 | description: 'The newly added bodyPart data' 58 | }) 59 | }) 60 | } 61 | } 62 | }, 63 | 400: { 64 | description: 'Bad request - Invalid input data', 65 | content: { 66 | 'application/json': { 67 | schema: z.object({ 68 | success: z.boolean(), 69 | error: z.string() 70 | }) 71 | } 72 | } 73 | }, 74 | 409: { 75 | description: 'Conflict - bodyPart name already exists' 76 | }, 77 | 500: { 78 | description: 'Internal server error' 79 | } 80 | } 81 | }), 82 | async (ctx) => { 83 | try { 84 | const body = await ctx.req.json() 85 | const response = await this.bodyPartService.createBodyPart(body) 86 | return ctx.json({ success: true, data: [response] }) 87 | } catch (error) { 88 | console.error('Error in adding bodypart:', error) 89 | if (error instanceof HTTPException) { 90 | return ctx.json({ success: false, error: error.message }, error.status) 91 | } 92 | return ctx.json({ success: false, error: 'Internal Server Error' }, 500) 93 | } 94 | } 95 | ) 96 | this.controller.openapi( 97 | createRoute({ 98 | method: 'get', 99 | path: '/bodyparts', 100 | tags: ['BodyParts'], 101 | summary: 'Retrive all bodyParts.', 102 | description: 'Retrive list of all bodyparts.', 103 | operationId: 'getBodyParts', 104 | responses: { 105 | 200: { 106 | description: 'Successful response with list of all bodyparts.', 107 | content: { 108 | 'application/json': { 109 | schema: z.object({ 110 | success: z.boolean().openapi({ 111 | description: 'Indicates whether the request was successful', 112 | type: 'boolean', 113 | example: true 114 | }), 115 | data: z.array(BodyPartModel).openapi({ 116 | description: 'Array of bodyparts.' 117 | }) 118 | }) 119 | } 120 | } 121 | }, 122 | 500: { 123 | description: 'Internal server error' 124 | } 125 | } 126 | }), 127 | async (ctx) => { 128 | const response = await this.bodyPartService.getBodyParts() 129 | return ctx.json({ success: true, data: response }) 130 | } 131 | ) 132 | this.controller.openapi( 133 | createRoute({ 134 | method: 'get', 135 | path: '/bodyparts/{bodyPartName}/exercises', 136 | tags: ['BodyParts'], 137 | summary: 'Retrive exercises by bodyPart', 138 | description: 'Retrive list of all bodyparts.', 139 | operationId: 'getExercisesByBodyPart', 140 | request: { 141 | params: z.object({ 142 | bodyPartName: z.string().openapi({ 143 | description: 'bodyparts name', 144 | type: 'string', 145 | example: 'waist', 146 | default: 'waist' 147 | }) 148 | }), 149 | query: z.object({ 150 | offset: z.coerce.number().nonnegative().optional().openapi({ 151 | title: 'Offset', 152 | description: 153 | 'The number of exercises to skip from the start of the list. Useful for pagination to fetch subsequent pages of results.', 154 | type: 'number', 155 | example: 10, 156 | default: 0 157 | }), 158 | limit: z.coerce.number().positive().max(100).optional().openapi({ 159 | title: 'Limit', 160 | description: 161 | 'The maximum number of exercises to return in the response. Limits the number of results for pagination purposes.', 162 | maximum: 100, 163 | minimum: 1, 164 | type: 'number', 165 | example: 10, 166 | default: 10 167 | }) 168 | }) 169 | }, 170 | responses: { 171 | 200: { 172 | description: 'Successful response with list of all exercises.', 173 | content: { 174 | 'application/json': { 175 | schema: z.object({ 176 | success: z.boolean().openapi({ 177 | description: 'Indicates whether the request was successful', 178 | type: 'boolean', 179 | example: true 180 | }), 181 | data: z.array(ExerciseModel).openapi({ 182 | description: 'Array of Exercises.' 183 | }) 184 | }) 185 | } 186 | } 187 | }, 188 | 500: { 189 | description: 'Internal server error' 190 | } 191 | } 192 | }), 193 | async (ctx) => { 194 | const { offset, limit = 10 } = ctx.req.valid('query') 195 | const search = ctx.req.param('bodyPartName') 196 | const { origin, pathname } = new URL(ctx.req.url) 197 | const response = await this.bodyPartService.getExercisesByBodyPart({ offset, limit, search }) 198 | return ctx.json({ 199 | success: true, 200 | data: { 201 | previousPage: 202 | response.currentPage > 1 203 | ? `${origin}${pathname}?offset=${(response.currentPage - 1) * limit}&limit=${limit}` 204 | : null, 205 | nextPage: 206 | response.currentPage < response.totalPages 207 | ? `${origin}${pathname}?offset=${response.currentPage * limit}&limit=${limit}` 208 | : null, 209 | ...response 210 | } 211 | }) 212 | } 213 | ) 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/modules/bodyparts/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './bodyPart.controller' 2 | -------------------------------------------------------------------------------- /src/modules/bodyparts/models/bodyPart.model.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | export const BodyPartModel = z.object({ 3 | name: z.string() 4 | }) 5 | -------------------------------------------------------------------------------- /src/modules/bodyparts/services/body-part.service.ts: -------------------------------------------------------------------------------- 1 | import { IBodyPartModel } from '#infra/mongodb/models/bodyparts/bodypart.entity.js' 2 | import { IExerciseModel } from '#infra/mongodb/models/exercises/exercise.entity.js' 3 | import { GetExerciseSerivceArgs } from '#modules/exercises/services/exercise.service.js' 4 | import { 5 | GetExercisesArgs, 6 | GetExercisesUseCase 7 | } from '#modules/exercises/use-cases/get-exercises/get-exercise.usecase.js' 8 | import { CreateBodyPartArgs, CreateBodyPartUseCase } from '../use-cases/create-bodypart' 9 | import { GetBodyPartsUseCase } from '../use-cases/get-bodyparts' 10 | 11 | export class BodyPartService { 12 | private readonly createBodyPartUseCase: CreateBodyPartUseCase 13 | private readonly getBodyPartsUseCase: GetBodyPartsUseCase 14 | private readonly getExercisesUseCase: GetExercisesUseCase 15 | 16 | constructor( 17 | private readonly bodyPartModel: IBodyPartModel, 18 | private readonly exerciseModel: IExerciseModel 19 | ) { 20 | this.createBodyPartUseCase = new CreateBodyPartUseCase(bodyPartModel) 21 | this.getBodyPartsUseCase = new GetBodyPartsUseCase(bodyPartModel) 22 | this.getExercisesUseCase = new GetExercisesUseCase(exerciseModel) 23 | } 24 | 25 | createBodyPart = (args: CreateBodyPartArgs) => { 26 | return this.createBodyPartUseCase.execute(args) 27 | } 28 | 29 | getBodyParts = () => { 30 | return this.getBodyPartsUseCase.execute() 31 | } 32 | 33 | getExercisesByBodyPart = (params: GetExerciseSerivceArgs) => { 34 | const query: GetExercisesArgs = { 35 | offset: params.offset, 36 | limit: params.limit, 37 | query: { 38 | bodyParts: { 39 | $all: [params.search] 40 | } 41 | } 42 | } 43 | 44 | return this.getExercisesUseCase.execute(query) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/modules/bodyparts/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './body-part.service' 2 | -------------------------------------------------------------------------------- /src/modules/bodyparts/use-cases/create-bodypart/create-bodypart.use-case.ts: -------------------------------------------------------------------------------- 1 | import { IUseCase } from '#common/types/use-case.type.js' 2 | import { IBodyPartDoc, IBodyPartModel } from '#infra/mongodb/models/bodyparts/bodypart.entity.js' 3 | import { HTTPException } from 'hono/http-exception' 4 | 5 | export interface CreateBodyPartArgs { 6 | name: string 7 | } 8 | 9 | export class CreateBodyPartUseCase implements IUseCase { 10 | constructor(private readonly bodyPartModel: IBodyPartModel) {} 11 | 12 | async execute({ name }: CreateBodyPartArgs): Promise { 13 | await this.checkIfBodyPartExists(name) 14 | return this.createBodyPart(name) 15 | } 16 | 17 | private async checkIfBodyPartExists(name: string): Promise { 18 | if (await this.bodyPartModel.isBodyPartExist(name)) { 19 | throw new HTTPException(409, { message: 'BodyPart name already exists' }) 20 | } 21 | } 22 | 23 | private async createBodyPart(name: string): Promise { 24 | return this.bodyPartModel.create({ name }) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/bodyparts/use-cases/create-bodypart/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-bodypart.use-case' 2 | -------------------------------------------------------------------------------- /src/modules/bodyparts/use-cases/get-bodyparts/get-bodypart.usecase.ts: -------------------------------------------------------------------------------- 1 | import { IUseCase } from '#common/types/use-case.type.js' 2 | import { IBodyPartDoc, IBodyPartModel } from '#infra/mongodb/models/bodyparts/bodypart.entity.js' 3 | 4 | export class GetBodyPartsUseCase implements IUseCase { 5 | constructor(private readonly bodyPartModel: IBodyPartModel) {} 6 | 7 | async execute(): Promise { 8 | return this.bodyPartModel.find({}) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/bodyparts/use-cases/get-bodyparts/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-bodypart.usecase' 2 | -------------------------------------------------------------------------------- /src/modules/equipments/controllers/equipment.controller.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '#common/types/route.type.js' 2 | import { createRoute, OpenAPIHono } from '@hono/zod-openapi' 3 | import { z } from 'zod' 4 | import { EquipmentModel } from '../models/equipment.model' 5 | import { EquipmentService } from '../services' 6 | import Equipment from '#infra/mongodb/models/equipments/equipment.schema.js' 7 | import { HTTPException } from 'hono/http-exception' 8 | import { ExerciseModel } from '#modules/exercises/models/exercise.model.js' 9 | import Exercise from '#infra/mongodb/models/exercises/exercise.schema.js' 10 | 11 | export class EquipmentController implements Routes { 12 | public controller: OpenAPIHono 13 | private readonly equipmentService: EquipmentService 14 | constructor() { 15 | this.controller = new OpenAPIHono() 16 | this.equipmentService = new EquipmentService(Equipment, Exercise) 17 | } 18 | 19 | public initRoutes() { 20 | this.controller.openapi( 21 | createRoute({ 22 | method: 'post', 23 | path: '/equipments', 24 | tags: ['Equipments'], 25 | summary: 'Add a new equipment to the database', 26 | description: 'This route is used to add a new equipment name to the database.', 27 | operationId: 'createEquipment', 28 | request: { 29 | body: { 30 | content: { 31 | 'application/json': { 32 | schema: z.object({ 33 | name: z.string().openapi({ 34 | title: 'Equipment Name', 35 | description: 'The name of the equipment to be added', 36 | type: 'string', 37 | example: 'band' 38 | }) 39 | }) 40 | } 41 | } 42 | } 43 | }, 44 | responses: { 45 | 201: { 46 | description: 'equipment successfully added to the database', 47 | content: { 48 | 'application/json': { 49 | schema: z.object({ 50 | success: z.boolean().openapi({ 51 | description: 'Indicates whether the request was successful', 52 | type: 'boolean', 53 | example: true 54 | }), 55 | data: z.array(EquipmentModel).openapi({ 56 | title: 'Added Equipment', 57 | description: 'The newly added equipment data' 58 | }) 59 | }) 60 | } 61 | } 62 | }, 63 | 400: { 64 | description: 'Bad request - Invalid input data', 65 | content: { 66 | 'application/json': { 67 | schema: z.object({ 68 | success: z.boolean(), 69 | error: z.string() 70 | }) 71 | } 72 | } 73 | }, 74 | 409: { 75 | description: 'Conflict - equipment name already exists' 76 | }, 77 | 500: { 78 | description: 'Internal server error' 79 | } 80 | } 81 | }), 82 | async (ctx) => { 83 | try { 84 | const body = await ctx.req.json() 85 | const response = await this.equipmentService.createEquipment(body) 86 | return ctx.json({ success: true, data: [response] }) 87 | } catch (error) { 88 | console.error('Error in adding equipment:', error) 89 | if (error instanceof HTTPException) { 90 | return ctx.json({ success: false, error: error.message }, error.status) 91 | } 92 | return ctx.json({ success: false, error: 'Internal Server Error' }, 500) 93 | } 94 | } 95 | ) 96 | this.controller.openapi( 97 | createRoute({ 98 | method: 'get', 99 | path: '/equipments', 100 | tags: ['Equipments'], 101 | summary: 'Retrive all equipments.', 102 | description: 'Retrive list of all equipments.', 103 | operationId: 'getMuscles', 104 | responses: { 105 | 200: { 106 | description: 'Successful response with list of all equipments.', 107 | content: { 108 | 'application/json': { 109 | schema: z.object({ 110 | success: z.boolean().openapi({ 111 | description: 'Indicates whether the request was successful', 112 | type: 'boolean', 113 | example: true 114 | }), 115 | data: z.array(EquipmentModel).openapi({ 116 | description: 'Array of equipments.' 117 | }) 118 | }) 119 | } 120 | } 121 | }, 122 | 500: { 123 | description: 'Internal server error' 124 | } 125 | } 126 | }), 127 | async (ctx) => { 128 | const response = await this.equipmentService.getEquipments() 129 | return ctx.json({ success: true, data: response }) 130 | } 131 | ) 132 | this.controller.openapi( 133 | createRoute({ 134 | method: 'get', 135 | path: '/equipments/{equipmentName}/exercises', 136 | tags: ['Equipments'], 137 | summary: 'Retrive exercises by equipments', 138 | description: 'Retrive list of all equipments.', 139 | operationId: 'getExercisesByEquipment', 140 | request: { 141 | params: z.object({ 142 | equipmentName: z.string().openapi({ 143 | description: 'equipments name', 144 | type: 'string', 145 | example: 'body weight', 146 | default: 'body weight' 147 | }) 148 | }), 149 | query: z.object({ 150 | offset: z.coerce.number().nonnegative().optional().openapi({ 151 | title: 'Offset', 152 | description: 153 | 'The number of exercises to skip from the start of the list. Useful for pagination to fetch subsequent pages of results.', 154 | type: 'number', 155 | example: 10, 156 | default: 0 157 | }), 158 | limit: z.coerce.number().positive().max(100).optional().openapi({ 159 | title: 'Limit', 160 | description: 161 | 'The maximum number of exercises to return in the response. Limits the number of results for pagination purposes.', 162 | maximum: 100, 163 | minimum: 1, 164 | type: 'number', 165 | example: 10, 166 | default: 10 167 | }) 168 | }) 169 | }, 170 | responses: { 171 | 200: { 172 | description: 'Successful response with list of all exercises.', 173 | content: { 174 | 'application/json': { 175 | schema: z.object({ 176 | success: z.boolean().openapi({ 177 | description: 'Indicates whether the request was successful', 178 | type: 'boolean', 179 | example: true 180 | }), 181 | data: z.array(ExerciseModel).openapi({ 182 | description: 'Array of Exercises.' 183 | }) 184 | }) 185 | } 186 | } 187 | }, 188 | 500: { 189 | description: 'Internal server error' 190 | } 191 | } 192 | }), 193 | async (ctx) => { 194 | const { offset, limit = 10 } = ctx.req.valid('query') 195 | const search = ctx.req.param('equipmentName') 196 | const { origin, pathname } = new URL(ctx.req.url) 197 | const response = await this.equipmentService.getExercisesByEquipment({ offset, limit, search }) 198 | return ctx.json({ 199 | success: true, 200 | data: { 201 | previousPage: 202 | response.currentPage > 1 203 | ? `${origin}${pathname}?offset=${(response.currentPage - 1) * limit}&limit=${limit}` 204 | : null, 205 | nextPage: 206 | response.currentPage < response.totalPages 207 | ? `${origin}${pathname}?offset=${response.currentPage * limit}&limit=${limit}` 208 | : null, 209 | ...response 210 | } 211 | }) 212 | } 213 | ) 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/modules/equipments/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './equipment.controller' 2 | -------------------------------------------------------------------------------- /src/modules/equipments/models/equipment.model.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | export const EquipmentModel = z.object({ 3 | name: z.string() 4 | }) 5 | -------------------------------------------------------------------------------- /src/modules/equipments/services/equipment.service.ts: -------------------------------------------------------------------------------- 1 | import { IEquipmentModel } from '#infra/mongodb/models/equipments/equipment.entity.js' 2 | import { IExerciseModel } from '#infra/mongodb/models/exercises/exercise.entity.js' 3 | import { GetExerciseSerivceArgs } from '#modules/exercises/services/exercise.service.js' 4 | import { 5 | GetExercisesArgs, 6 | GetExercisesUseCase 7 | } from '#modules/exercises/use-cases/get-exercises/get-exercise.usecase.js' 8 | import { CreateEquipmentArgs, CreateEquipmentUseCase } from '../use-cases/create-equipment' 9 | import { GetEquipmentsUseCase } from '../use-cases/get-equipments' 10 | 11 | export class EquipmentService { 12 | private readonly createEquipmentUseCase: CreateEquipmentUseCase 13 | private readonly getEquipmentUseCase: GetEquipmentsUseCase 14 | private readonly getExercisesUseCase: GetExercisesUseCase 15 | 16 | constructor( 17 | private readonly equipmentModel: IEquipmentModel, 18 | private readonly exerciseModel: IExerciseModel 19 | ) { 20 | this.createEquipmentUseCase = new CreateEquipmentUseCase(equipmentModel) 21 | this.getEquipmentUseCase = new GetEquipmentsUseCase(equipmentModel) 22 | this.getExercisesUseCase = new GetExercisesUseCase(exerciseModel) 23 | } 24 | 25 | createEquipment = (args: CreateEquipmentArgs) => { 26 | return this.createEquipmentUseCase.execute(args) 27 | } 28 | 29 | getEquipments = () => { 30 | return this.getEquipmentUseCase.execute() 31 | } 32 | getExercisesByEquipment = (params: GetExerciseSerivceArgs) => { 33 | const query: GetExercisesArgs = { 34 | offset: params.offset, 35 | limit: params.limit, 36 | query: { 37 | equipments: { 38 | $all: [params.search] 39 | } 40 | } 41 | } 42 | 43 | return this.getExercisesUseCase.execute(query) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/modules/equipments/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './equipment.service' 2 | -------------------------------------------------------------------------------- /src/modules/equipments/use-cases/create-equipment/create-equipment.use-case.ts: -------------------------------------------------------------------------------- 1 | import { IUseCase } from '#common/types/use-case.type.js' 2 | import { IEquipmentDoc, IEquipmentModel } from '#infra/mongodb/models/equipments/equipment.entity.js' 3 | import { HTTPException } from 'hono/http-exception' 4 | 5 | export interface CreateEquipmentArgs { 6 | name: string 7 | } 8 | 9 | export class CreateEquipmentUseCase implements IUseCase { 10 | constructor(private readonly euipmentModel: IEquipmentModel) {} 11 | 12 | async execute({ name }: CreateEquipmentArgs): Promise { 13 | await this.checkIfEquipmentExists(name) 14 | return this.createEquipment(name) 15 | } 16 | 17 | private async checkIfEquipmentExists(name: string): Promise { 18 | if (await this.euipmentModel.isEquipmentExist(name)) { 19 | throw new HTTPException(409, { message: 'Equipment name already exists' }) 20 | } 21 | } 22 | 23 | private async createEquipment(name: string): Promise { 24 | return this.euipmentModel.create({ name }) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/equipments/use-cases/create-equipment/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-equipment.use-case' 2 | -------------------------------------------------------------------------------- /src/modules/equipments/use-cases/get-equipments/get-equipment.usecase.ts: -------------------------------------------------------------------------------- 1 | import { IUseCase } from '#common/types/use-case.type.js' 2 | import { IEquipmentDoc, IEquipmentModel } from '#infra/mongodb/models/equipments/equipment.entity.js' 3 | 4 | export class GetEquipmentsUseCase implements IUseCase { 5 | constructor(private readonly equipmentModel: IEquipmentModel) {} 6 | 7 | async execute(): Promise { 8 | return this.equipmentModel.find({}) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/equipments/use-cases/get-equipments/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-equipment.usecase' 2 | -------------------------------------------------------------------------------- /src/modules/exercises/controllers/exercise.controller.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '#common/types/route.type.js' 2 | import { createRoute, OpenAPIHono } from '@hono/zod-openapi' 3 | import { z } from 'zod' 4 | import { ExerciseService } from '../services/exercise.service' 5 | import Exercise from '#infra/mongodb/models/exercises/exercise.schema.js' 6 | import { ExerciseModel } from '../models/exercise.model' 7 | import { HTTPException } from 'hono/http-exception' 8 | 9 | export class ExerciseController implements Routes { 10 | public controller: OpenAPIHono 11 | private readonly exerciseService: ExerciseService 12 | constructor() { 13 | this.controller = new OpenAPIHono() 14 | this.exerciseService = new ExerciseService(Exercise) 15 | } 16 | 17 | public initRoutes() { 18 | this.controller.openapi( 19 | createRoute({ 20 | method: 'post', 21 | path: '/exercises', 22 | tags: ['Exercises'], 23 | summary: 'Add a new exercises to the database', 24 | description: 'This route is used to add a new exercises to database.', 25 | operationId: 'createExercise', 26 | request: { 27 | body: { 28 | content: { 29 | 'application/json': { 30 | schema: z.object({ 31 | exerciseId: z.string().openapi({ 32 | title: 'Exercise ID', 33 | description: 'Unique identifier for the exercise.', 34 | type: 'string', 35 | example: 'KCBKjma' 36 | }), 37 | name: z.string().openapi({ 38 | title: 'Exercise Name', 39 | description: 'The name of the exercise.', 40 | type: 'string', 41 | example: 'Band Jack Knife Sit-up' 42 | }), 43 | gifUrl: z.string().openapi({ 44 | title: 'Exercise GIF URL', 45 | description: 'URL of the GIF demonstrating the exercise.', 46 | type: 'string', 47 | example: 'https://ucarecdn.com/05fcc879-04d4-4222-8896-e3772a8a3060/KCBKjma.gif' 48 | }), 49 | targetMuscles: z.array(z.string()).openapi({ 50 | title: 'Target Muscles', 51 | description: 'Primary muscles targeted by the exercise.', 52 | type: 'array', 53 | items: { 54 | type: 'string' 55 | }, 56 | example: ['abs'] 57 | }), 58 | bodyParts: z.array(z.string()).openapi({ 59 | title: 'Body Parts', 60 | description: 'Body parts involved in the exercise.', 61 | type: 'array', 62 | items: { 63 | type: 'string' 64 | }, 65 | example: ['waist', 'back'] 66 | }), 67 | equipments: z.array(z.string()).openapi({ 68 | title: 'Equipments', 69 | description: 'Equipment required to perform the exercise.', 70 | type: 'array', 71 | items: { 72 | type: 'string' 73 | }, 74 | example: ['band'] 75 | }), 76 | secondaryMuscles: z.array(z.string()).openapi({ 77 | title: 'Secondary Muscles', 78 | description: 'Secondary muscles that are worked during the exercise.', 79 | type: 'array', 80 | items: { 81 | type: 'string' 82 | }, 83 | example: ['abs', 'lats'] 84 | }), 85 | instructions: z.array(z.string()).openapi({ 86 | title: 'Exercise Instructions', 87 | description: 'Step-by-step instructions to perform the exercise.', 88 | type: 'array', 89 | items: { 90 | type: 'string' 91 | }, 92 | example: [ 93 | 'Step 1: Start with...', 94 | 'Step 2: Move into...', 95 | 'Step 3: Move into...', 96 | 'Step 4: Move into...', 97 | 'Step 5: Move into...' 98 | ] 99 | }) 100 | }) 101 | } 102 | } 103 | } 104 | }, 105 | responses: { 106 | 201: { 107 | description: 'Exercise successfully added to the database.', 108 | content: { 109 | 'application/json': { 110 | schema: z.object({ 111 | success: z.boolean().openapi({ 112 | description: 'Indicates whether the exercise was successfully added to the database.', 113 | type: 'boolean', 114 | example: true 115 | }), 116 | data: z.array(ExerciseModel).openapi({ 117 | title: 'Added Exercise Data', 118 | description: 'Details of the newly added exercise, including relevant information.' 119 | }) 120 | }) 121 | } 122 | } 123 | }, 124 | 400: { 125 | description: 'Bad Request - The input data for the exercise is invalid or incomplete.', 126 | content: { 127 | 'application/json': { 128 | schema: z.object({ 129 | success: z.boolean(), 130 | error: z.string() 131 | }) 132 | } 133 | } 134 | }, 135 | 409: { 136 | description: 'Conflict - An exercise with the same name already exists in the database.' 137 | }, 138 | 500: { 139 | description: 'Internal Server Error - An unexpected error occurred on the server.' 140 | } 141 | } 142 | }), 143 | async (ctx) => { 144 | const body = await ctx.req.json() 145 | const response = await this.exerciseService.createExercise(body) 146 | return ctx.json({ success: true, data: [response] }) 147 | } 148 | ) 149 | this.controller.openapi( 150 | createRoute({ 151 | method: 'get', 152 | path: '/exercises', 153 | tags: ['Exercises'], 154 | summary: 'Retrive all exercises.', 155 | description: 'Retrive list of all the exercises.', 156 | operationId: 'getExercises', 157 | request: { 158 | query: z.object({ 159 | search: z.string().optional().openapi({ 160 | title: 'Search Query', 161 | description: 162 | 'A string to filter exercises based on a search term. This can be used to find specific exercises by name or description.', 163 | type: 'string', 164 | example: 'cardio', 165 | default: '' 166 | }), 167 | offset: z.coerce.number().nonnegative().optional().openapi({ 168 | title: 'Offset', 169 | description: 170 | 'The number of exercises to skip from the start of the list. Useful for pagination to fetch subsequent pages of results.', 171 | type: 'number', 172 | example: 10, 173 | default: 0 174 | }), 175 | limit: z.coerce.number().positive().max(100).optional().openapi({ 176 | title: 'Limit', 177 | description: 178 | 'The maximum number of exercises to return in the response. Limits the number of results for pagination purposes.', 179 | maximum: 100, 180 | minimum: 1, 181 | type: 'number', 182 | example: 10, 183 | default: 10 184 | }) 185 | }) 186 | }, 187 | responses: { 188 | 200: { 189 | description: 'Successful response with list of all exercises.', 190 | content: { 191 | 'application/json': { 192 | schema: z.object({ 193 | success: z.boolean().openapi({ 194 | description: 'Indicates whether the request was successful', 195 | type: 'boolean', 196 | example: true 197 | }), 198 | data: z.array(ExerciseModel).openapi({ 199 | description: 'Array of Exercises.' 200 | }) 201 | }) 202 | } 203 | } 204 | }, 205 | 500: { 206 | description: 'Internal server error' 207 | } 208 | } 209 | }), 210 | async (ctx) => { 211 | const { offset, limit = 10, search } = ctx.req.valid('query') 212 | const { origin, pathname } = new URL(ctx.req.url) 213 | const response = await this.exerciseService.getExercise({ offset, limit, search }) 214 | return ctx.json({ 215 | success: true, 216 | data: { 217 | previousPage: 218 | response.currentPage > 1 219 | ? `${origin}${pathname}?offset=${(response.currentPage - 1) * limit}&limit=${limit}` 220 | : null, 221 | nextPage: 222 | response.currentPage < response.totalPages 223 | ? `${origin}${pathname}?offset=${response.currentPage * limit}&limit=${limit}` 224 | : null, 225 | ...response 226 | } 227 | }) 228 | } 229 | ) 230 | // autocomplete endpoint 231 | this.controller.openapi( 232 | createRoute({ 233 | method: 'get', 234 | path: '/exercises/autocomplete', 235 | tags: ['Exercises'], 236 | summary: 'Autocomplete Exercise Search', 237 | description: 238 | 'Retrieves a list of exercise names that match the search term using fuzzy search. This endpoint provides autocomplete suggestions based on the MongoDB autocomplete feature, helping users find exercises quickly as they type.', 239 | operationId: 'autocompleteExercises', 240 | request: { 241 | query: z.object({ 242 | search: z.string().optional().openapi({ 243 | title: 'Search Term', 244 | description: 245 | 'A string used to filter exercises based on a search term. Supports fuzzy matching to suggest relevant exercise names as the user types.', 246 | type: 'string', 247 | example: 'cardio', 248 | default: '' 249 | }) 250 | }) 251 | }, 252 | responses: { 253 | 200: { 254 | description: 'Successful response with a list of exercise name suggestions.', 255 | content: { 256 | 'application/json': { 257 | schema: z.object({ 258 | success: z.boolean().openapi({ 259 | description: 'Indicates whether the request was successful.', 260 | type: 'boolean', 261 | example: true 262 | }), 263 | data: z.array(z.string()).openapi({ 264 | description: 'Array of suggested exercise names based on the search term.' 265 | }) 266 | }) 267 | } 268 | } 269 | }, 270 | 500: { 271 | description: 'Internal server error' 272 | } 273 | } 274 | }), 275 | async (ctx) => { 276 | const { search } = ctx.req.valid('query') 277 | const response = await this.exerciseService.getAutoCompleteSuggestions({ search }) 278 | return ctx.json({ 279 | success: true, 280 | data: response 281 | }) 282 | } 283 | ) 284 | 285 | this.controller.openapi( 286 | createRoute({ 287 | method: 'get', 288 | path: '/exercises/{exerciseId}', 289 | tags: ['Exercises'], 290 | summary: 'Get exercise by ID', 291 | description: 'Retrieves a specific exercise by its unique identifier.', 292 | operationId: 'getExerciseById', 293 | request: { 294 | params: z.object({ 295 | exerciseId: z.string().openapi({ 296 | title: 'Exercise ID', 297 | description: 'The unique identifier of the exercise to retrieve.', 298 | type: 'string', 299 | example: 'ztAa1RK', 300 | default: 'ztAa1RK' 301 | }) 302 | }) 303 | }, 304 | responses: { 305 | 200: { 306 | description: 'Successful response with the exercise details.', 307 | content: { 308 | 'application/json': { 309 | schema: z.object({ 310 | success: z.boolean().openapi({ 311 | description: 'Indicates whether the request was successful.', 312 | type: 'boolean', 313 | example: true 314 | }), 315 | data: ExerciseModel.openapi({ 316 | description: 'The retrieved exercise details.' 317 | }) 318 | }) 319 | } 320 | } 321 | }, 322 | 404: { 323 | description: 'Exercise not found' 324 | }, 325 | 500: { 326 | description: 'Internal server error' 327 | } 328 | } 329 | }), 330 | async (ctx) => { 331 | const exerciseId = ctx.req.param('exerciseId') 332 | const exercise = await this.exerciseService.getExerciseById(exerciseId) 333 | 334 | if (!exercise) { 335 | throw new HTTPException(404, { message: 'Exercise not found' }) 336 | } 337 | 338 | return ctx.json({ 339 | success: true, 340 | data: exercise 341 | }) 342 | } 343 | ) 344 | } 345 | } 346 | -------------------------------------------------------------------------------- /src/modules/exercises/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './exercise.controller' 2 | -------------------------------------------------------------------------------- /src/modules/exercises/models/exercise.model.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | export const ExerciseModel = z.object({ 3 | exerciseId: z.string(), 4 | name: z.string(), 5 | gifUrl: z.string(), 6 | targetMuscles: z.array(z.string()), 7 | bodyParts: z.array(z.string()), 8 | equipments: z.array(z.string()), 9 | secondaryMuscles: z.array(z.string()), 10 | instructions: z.array(z.string()) 11 | }) 12 | -------------------------------------------------------------------------------- /src/modules/exercises/services/exercise.service.ts: -------------------------------------------------------------------------------- 1 | import { IExerciseModel } from '#infra/mongodb/models/exercises/exercise.entity.js' 2 | import { CreateExerciseArgs, CreateExerciseUseCase } from '../use-cases/create-exercise' 3 | import { 4 | GetAutoCompleteSuggestionsArgs, 5 | GetAutoCompleteSuggestionsUseCase 6 | } from '../use-cases/get-autocomplete-suggestions' 7 | import { GetExerciseByIdUseCase } from '../use-cases/get-exercises-by-id' 8 | import { GetExercisesArgs, GetExercisesUseCase } from '../use-cases/get-exercises/get-exercise.usecase' 9 | 10 | export interface GetExerciseSerivceArgs { 11 | offset?: number 12 | limit?: number 13 | search?: string 14 | } 15 | export class ExerciseService { 16 | private readonly createExerciseUseCase: CreateExerciseUseCase 17 | private readonly getExercisesUseCase: GetExercisesUseCase 18 | private readonly getAutoCompleteSuggestionsUseCase: GetAutoCompleteSuggestionsUseCase 19 | private readonly getExerciseByIdUseCase: GetExerciseByIdUseCase 20 | constructor(private readonly exerciseModel: IExerciseModel) { 21 | this.createExerciseUseCase = new CreateExerciseUseCase(exerciseModel) 22 | this.getExercisesUseCase = new GetExercisesUseCase(exerciseModel) 23 | this.getAutoCompleteSuggestionsUseCase = new GetAutoCompleteSuggestionsUseCase(exerciseModel) 24 | this.getExerciseByIdUseCase = new GetExerciseByIdUseCase(exerciseModel) 25 | } 26 | 27 | createExercise = (params: CreateExerciseArgs) => { 28 | return this.createExerciseUseCase.execute(params) 29 | } 30 | getExercise = (params: GetExerciseSerivceArgs) => { 31 | const query: GetExercisesArgs = { 32 | query: { ...(params.search && { $text: { $search: params.search } }) }, 33 | offset: params.offset, 34 | limit: params.limit 35 | } 36 | return this.getExercisesUseCase.execute(query) 37 | } 38 | getAutoCompleteSuggestions = (params: GetAutoCompleteSuggestionsArgs) => { 39 | return this.getAutoCompleteSuggestionsUseCase.execute(params) 40 | } 41 | 42 | getExerciseById = (exerciseId: string) => { 43 | return this.getExerciseByIdUseCase.execute(exerciseId) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/modules/exercises/services/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cyberboyanmol/exercisedb-api/c9c34b7124de25bfe9758f90005c76b513dbe738/src/modules/exercises/services/index.ts -------------------------------------------------------------------------------- /src/modules/exercises/use-cases/create-exercise/create-exercise.usecase.ts: -------------------------------------------------------------------------------- 1 | import { IUseCase } from '#common/types/use-case.type.js' 2 | import { IExerciseDoc, IExerciseModel } from '#infra/mongodb/models/exercises/exercise.entity.js' 3 | import { HTTPException } from 'hono/http-exception' 4 | 5 | export interface CreateExerciseArgs { 6 | exerciseId: string 7 | name: string 8 | gifUrl: string 9 | instructions: string[] 10 | targetMuscles: string[] 11 | bodyParts: string[] 12 | equipments: string[] 13 | secondaryMuscles: string[] 14 | } 15 | 16 | export class CreateExerciseUseCase implements IUseCase { 17 | constructor(private readonly exerciseModel: IExerciseModel) {} 18 | 19 | async execute(params: CreateExerciseArgs): Promise { 20 | await this.checkIfExerciseExists(params.exerciseId) 21 | return this.createExercise(params) 22 | } 23 | 24 | private async checkIfExerciseExists(exerciseId: string): Promise { 25 | console.log(await this.exerciseModel.isExerciseExist(exerciseId)) 26 | if (await this.exerciseModel.isExerciseExist(exerciseId)) { 27 | throw new HTTPException(409, { message: 'Exercise with exerciseId already exists' }) 28 | } 29 | } 30 | 31 | private async createExercise(params: CreateExerciseArgs): Promise { 32 | return this.exerciseModel.create({ 33 | exerciseId: params.exerciseId, 34 | name: params.name, 35 | gifUrl: params.gifUrl, 36 | bodyParts: params.bodyParts, 37 | targetMuscles: params.targetMuscles, 38 | secondaryMuscles: params.secondaryMuscles, 39 | equipments: params.equipments, 40 | instructions: params.instructions 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/modules/exercises/use-cases/create-exercise/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-exercise.usecase' 2 | -------------------------------------------------------------------------------- /src/modules/exercises/use-cases/get-autocomplete-suggestions/get-autocomplete-suggestions.usecase.ts: -------------------------------------------------------------------------------- 1 | import { IUseCase } from '#common/types/use-case.type.js' 2 | import { IExerciseDoc, IExerciseModel } from '#infra/mongodb/models/exercises/exercise.entity.js' 3 | 4 | export interface GetAutoCompleteSuggestionsArgs { 5 | search?: string 6 | } 7 | export class GetAutoCompleteSuggestionsUseCase implements IUseCase { 8 | constructor(private readonly exerciseModel: IExerciseModel) {} 9 | 10 | async execute({ search = '' }: GetAutoCompleteSuggestionsArgs): Promise { 11 | try { 12 | if (!search) { 13 | return [] 14 | } 15 | 16 | const autocompleteResults = await this.exerciseModel.aggregate([ 17 | { 18 | $search: { 19 | index: 'exercises', 20 | autocomplete: { 21 | query: search, 22 | path: 'name', 23 | fuzzy: { 24 | maxEdits: 1, 25 | prefixLength: 1 26 | } 27 | } 28 | } 29 | }, 30 | { $limit: 10 }, 31 | { 32 | $project: { 33 | name: 1, 34 | gifUrl: 1, 35 | exerciseId: 1, 36 | _id: 0, 37 | score: { $meta: 'searchScore' } 38 | } 39 | }, 40 | { $sort: { score: -1 } } 41 | ]) 42 | 43 | return autocompleteResults 44 | } catch (error) { 45 | throw new Error('Failed to generate exercises suggestions') 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/modules/exercises/use-cases/get-autocomplete-suggestions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-autocomplete-suggestions.usecase' 2 | -------------------------------------------------------------------------------- /src/modules/exercises/use-cases/get-exercises-by-id/get-exercise-by-id.usecase.ts: -------------------------------------------------------------------------------- 1 | import { IUseCase } from '#common/types/use-case.type.js' 2 | import { IExerciseDoc, IExerciseModel } from '#infra/mongodb/models/exercises/exercise.entity.js' 3 | 4 | export class GetExerciseByIdUseCase implements IUseCase { 5 | constructor(private readonly exerciseModel: IExerciseModel) {} 6 | 7 | async execute(exerciseId: string): Promise { 8 | return this.exerciseModel.findOne({ 9 | exerciseId 10 | }) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/modules/exercises/use-cases/get-exercises-by-id/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-exercise-by-id.usecase' 2 | -------------------------------------------------------------------------------- /src/modules/exercises/use-cases/get-exercises/get-exercise.usecase.ts: -------------------------------------------------------------------------------- 1 | import { IUseCase } from '#common/types/use-case.type.js' 2 | import { IExerciseDoc, IExerciseModel } from '#infra/mongodb/models/exercises/exercise.entity.js' 3 | 4 | export interface GetExercisesArgs { 5 | offset?: number 6 | limit?: number 7 | query?: Record 8 | sort?: Record 9 | } 10 | 11 | export interface GetExercisesReturnArgs { 12 | exercises: IExerciseDoc[] 13 | totalPages: number 14 | totalExercises: number 15 | currentPage: number 16 | } 17 | 18 | export class GetExercisesUseCase implements IUseCase { 19 | constructor(private readonly exerciseModel: IExerciseModel) {} 20 | 21 | async execute({ offset, limit, query = {}, sort = {} }: GetExercisesArgs): Promise { 22 | try { 23 | const safeOffset = Math.max(0, Number(offset) || 0) 24 | const safeLimit = Math.max(1, Math.min(100, Number(limit) || 10)) 25 | 26 | const totalCount = await this.exerciseModel.countDocuments(query) 27 | const totalPages = Math.ceil(totalCount / safeLimit) 28 | const currentPage = Math.floor(safeOffset / safeLimit) + 1 29 | 30 | const result = await this.exerciseModel.find(query).sort(sort).skip(safeOffset).limit(safeLimit).exec() 31 | 32 | return { 33 | totalPages, 34 | totalExercises: totalCount, 35 | currentPage, 36 | exercises: result 37 | } 38 | } catch (error) { 39 | throw new Error('Failed to fetch exercises') 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/modules/exercises/use-cases/get-exercises/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-exercise.usecase' 2 | -------------------------------------------------------------------------------- /src/modules/images/controllers/image.controller.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '#common/types/route.type.js' 2 | import { createRoute, OpenAPIHono } from '@hono/zod-openapi' 3 | import { z } from 'zod' 4 | import { ImageService } from '../services' 5 | import { toBuffer } from 'bun:ffi' 6 | import { stream } from 'hono/streaming' 7 | import axios from 'axios' 8 | import { HTTPException } from 'hono/http-exception' 9 | 10 | export class ImagesController implements Routes { 11 | public controller: OpenAPIHono 12 | private readonly imageService: ImageService 13 | constructor() { 14 | this.controller = new OpenAPIHono() 15 | this.imageService = new ImageService() 16 | } 17 | 18 | public initRoutes() { 19 | this.controller.openapi( 20 | createRoute({ 21 | method: 'post', 22 | path: '/images/upload', 23 | tags: ['Images'], 24 | summary: 'Upload a GIF image', 25 | description: 'This route allows users to upload a GIF image file.', 26 | operationId: 'uploadGifImage', 27 | request: { 28 | body: { 29 | content: { 30 | 'multipart/form-data': { 31 | schema: z.object({ 32 | file: z.any().openapi({ 33 | title: 'GIF File', 34 | description: 'The GIF image file to be uploaded', 35 | type: 'string', 36 | format: 'binary' 37 | }) 38 | }) 39 | } 40 | } 41 | } 42 | }, 43 | responses: { 44 | 201: { 45 | description: 'GIF image successfully uploaded', 46 | content: { 47 | 'application/json': { 48 | schema: z.object({ 49 | success: z.boolean().openapi({ 50 | description: 'Indicates whether the upload was successful', 51 | type: 'boolean', 52 | example: true 53 | }), 54 | data: z 55 | .object({ 56 | publicUrl: z.string().openapi({ 57 | title: 'Image URL', 58 | description: 'The URL where the uploaded GIF image can be accessed', 59 | example: 'https://exercisedb.vercel.app/Images/3omWx6P.gif' 60 | }) 61 | }) 62 | .openapi({ 63 | title: 'Uploaded Image Data', 64 | description: 'Details of the uploaded GIF image' 65 | }) 66 | }) 67 | } 68 | } 69 | }, 70 | 400: { 71 | description: 'Bad request - Invalid file format or missing file', 72 | content: { 73 | 'application/json': { 74 | schema: z.object({ 75 | success: z.boolean(), 76 | error: z.string() 77 | }) 78 | } 79 | } 80 | }, 81 | 415: { 82 | description: 'Unsupported Media Type - Only GIF files are allowed' 83 | }, 84 | 500: { 85 | description: 'Internal server error' 86 | } 87 | } 88 | }), 89 | async (ctx) => { 90 | const { origin, pathname } = new URL(ctx.req.url) 91 | const body = await ctx.req.parseBody() 92 | const file = body['file'] as File 93 | if (!file || file.type !== 'image/gif') { 94 | return ctx.json({ success: false, error: 'Invalid file type. Only GIF files are allowed.' }, 400) 95 | } 96 | 97 | const { publicUrl } = await this.imageService.uploadImage(file) 98 | 99 | return ctx.json({ 100 | success: true, 101 | data: { publicUrl: `${origin}/api/v1/Images/${publicUrl.split('/').slice(-1)}` } 102 | }) 103 | } 104 | ) 105 | this.controller.openapi( 106 | createRoute({ 107 | method: 'get', 108 | path: '/images/{imageName}', 109 | tags: ['Images'], 110 | summary: 'View a GIF image', 111 | description: 'This route allows users to view a GIF image by its name.', 112 | operationId: 'viewGifImage', 113 | request: { 114 | params: z.object({ 115 | imageName: z.string().openapi({ 116 | description: 'imageName of image to view', 117 | type: 'string', 118 | example: '3omWx6P.gif or 3omWx6P', 119 | default: '3omWx6P.gif' 120 | }) 121 | }) 122 | }, 123 | responses: { 124 | 200: { 125 | description: 'GIF image successfully retrieved', 126 | content: { 127 | 'image/gif': { 128 | schema: {} 129 | } 130 | } 131 | }, 132 | 404: { 133 | description: 'Image not found or URL expired' 134 | }, 135 | 500: { 136 | description: 'Internal server error' 137 | } 138 | } 139 | }), 140 | 141 | async (ctx) => { 142 | try { 143 | const imageName = ctx.req.param('imageName') 144 | return ctx.redirect(`https://cdn-exercisedb.vercel.app/api/v1/images/${imageName}`) 145 | } catch (err) { 146 | if (err instanceof HTTPException) { 147 | return ctx.json({ success: false, error: err.message }, err.status) 148 | } 149 | return ctx.json({ success: false, error: 'Internal Server Error' }, 500) 150 | } 151 | } 152 | ) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/modules/images/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './image.controller' 2 | -------------------------------------------------------------------------------- /src/modules/images/services/image.service.ts: -------------------------------------------------------------------------------- 1 | import { Stream } from 'stream' 2 | import { ViewImageUseCase } from '../use-cases/view-image' 3 | import { UploadImageUseCase } from '../use-cases/upload-image/upload-gif.usecase' 4 | 5 | export class ImageService { 6 | private readonly uploadImageUseCase: UploadImageUseCase 7 | private readonly viewImageUseCase: ViewImageUseCase 8 | constructor() { 9 | this.uploadImageUseCase = new UploadImageUseCase() 10 | this.viewImageUseCase = new ViewImageUseCase() 11 | } 12 | 13 | uploadImage = (file: File) => { 14 | return this.uploadImageUseCase.execute(file) 15 | } 16 | viewImage = (fileName: string): Promise => { 17 | return this.viewImageUseCase.execute(fileName) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/modules/images/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './image.service' 2 | -------------------------------------------------------------------------------- /src/modules/images/use-cases/upload-image/index.ts: -------------------------------------------------------------------------------- 1 | export * from './upload-gif.usecase' 2 | -------------------------------------------------------------------------------- /src/modules/images/use-cases/upload-image/upload-gif.usecase.ts: -------------------------------------------------------------------------------- 1 | import { IUseCase } from '#common/types/use-case.type.js' 2 | import { supabase } from '#infra/supabase' 3 | 4 | export interface UploadImageUseCaseReturnArgs { 5 | publicUrl: string 6 | } 7 | 8 | export class UploadImageUseCase implements IUseCase { 9 | constructor() {} 10 | async execute(file: File): Promise { 11 | const { data, error } = await supabase.storage 12 | .from(process.env.SUPABASE_BUCKET_NAME!) 13 | .upload(`${file.name}`, file, { 14 | contentType: `${file.type}`, 15 | upsert: false 16 | }) 17 | if (error) throw error 18 | const { data: image } = await supabase.storage.from(process.env.SUPABASE_BUCKET_NAME!).getPublicUrl(data.path) 19 | return image 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/modules/images/use-cases/view-image/index.ts: -------------------------------------------------------------------------------- 1 | export * from './view-image.usecase' 2 | -------------------------------------------------------------------------------- /src/modules/images/use-cases/view-image/view-image.usecase.ts: -------------------------------------------------------------------------------- 1 | import { IUseCase } from '#common/types/use-case.type.js' 2 | import { redisStorageCache } from '#infra/redis/redis.client.js' 3 | import { RedisService } from '#infra/redis/redis.service.js' 4 | import axios, { AxiosResponse } from 'axios' 5 | import { HTTPException } from 'hono/http-exception' 6 | 7 | export class ViewImageUseCase implements IUseCase { 8 | private readonly redisService: RedisService 9 | constructor() { 10 | this.redisService = new RedisService(redisStorageCache) 11 | } 12 | 13 | async execute(fileName: string): Promise { 14 | const cacheKey = fileName.endsWith('.gif') ? fileName : `${fileName}.gif` 15 | const sourceUrl = fileName.endsWith('.gif') 16 | ? `${process.env.SUPABASE_BUCKET_URL}/${process.env.SUPABASE_BUCKET_NAME}/${fileName}` 17 | : `${process.env.SUPABASE_BUCKET_URL}/${process.env.SUPABASE_BUCKET_NAME}/${fileName}.gif` 18 | 19 | try { 20 | const cachedImage = await this.redisService.getBuffer('image', cacheKey) 21 | 22 | if (cachedImage) { 23 | return cachedImage 24 | } 25 | const response = await axios.get(sourceUrl, { 26 | responseType: 'arraybuffer' 27 | }) 28 | 29 | if (response.status === 404 || response.status === 400 || response.status !== 200) { 30 | throw new HTTPException(404, { message: 'Image not found or URL expired' }) 31 | } 32 | 33 | const imageBuffer = response.data 34 | await this.redisService.setBuffer('image', cacheKey, imageBuffer) 35 | 36 | return imageBuffer 37 | } catch (error) { 38 | if (axios.isAxiosError(error)) { 39 | const statusCode = error.response?.status 40 | 41 | switch (statusCode) { 42 | case 400: 43 | case 404: 44 | throw new HTTPException(statusCode, { message: `Image not found or URL expired.` }) 45 | default: 46 | throw new HTTPException(500, { message: 'Internal server error' }) 47 | } 48 | } else { 49 | throw new HTTPException(500, { message: 'Internal server error' }) 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/modules/index.ts: -------------------------------------------------------------------------------- 1 | export * from './muscles/controllers' 2 | export * from './equipments/controllers' 3 | export * from './bodyparts/controllers' 4 | export * from './exercises/controllers' 5 | -------------------------------------------------------------------------------- /src/modules/muscles/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './muscle.controller' 2 | -------------------------------------------------------------------------------- /src/modules/muscles/controllers/muscle.controller.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '#common/types/route.type.js' 2 | import { createRoute, OpenAPIHono } from '@hono/zod-openapi' 3 | import { z } from 'zod' 4 | import { MuscleModel } from '../models/muscle.model' 5 | import { MuscleService } from '../services' 6 | import Muscle from '#infra/mongodb/models/muscles/muscle.schema.js' 7 | import { HTTPException } from 'hono/http-exception' 8 | import Exercise from '#infra/mongodb/models/exercises/exercise.schema.js' 9 | import { ExerciseModel } from '#modules/exercises/models/exercise.model.js' 10 | 11 | export class MuscleController implements Routes { 12 | public controller: OpenAPIHono 13 | private readonly muscleService: MuscleService 14 | constructor() { 15 | this.controller = new OpenAPIHono() 16 | this.muscleService = new MuscleService(Muscle, Exercise) 17 | } 18 | 19 | public initRoutes() { 20 | this.controller.openapi( 21 | createRoute({ 22 | method: 'post', 23 | path: '/muscles', 24 | tags: ['Muscles'], 25 | summary: 'Add a new muscle to the database', 26 | description: 'This route is used to add a new muscle name to the database.', 27 | operationId: 'createMuscle', 28 | request: { 29 | body: { 30 | content: { 31 | 'application/json': { 32 | schema: z.object({ 33 | name: z.string().openapi({ 34 | title: 'Muscle Name', 35 | description: 'The name of the muscle to be added', 36 | type: 'string', 37 | example: 'biceps' 38 | }) 39 | }) 40 | } 41 | } 42 | } 43 | }, 44 | responses: { 45 | 201: { 46 | description: 'Muscle successfully added to the database', 47 | content: { 48 | 'application/json': { 49 | schema: z.object({ 50 | success: z.boolean().openapi({ 51 | description: 'Indicates whether the request was successful', 52 | type: 'boolean', 53 | example: true 54 | }), 55 | data: z.array(MuscleModel).openapi({ 56 | title: 'Added Muscle', 57 | description: 'The newly added muscle data' 58 | }) 59 | }) 60 | } 61 | } 62 | }, 63 | 400: { 64 | description: 'Bad request - Invalid input data', 65 | content: { 66 | 'application/json': { 67 | schema: z.object({ 68 | success: z.boolean(), 69 | error: z.string() 70 | }) 71 | } 72 | } 73 | }, 74 | 409: { 75 | description: 'Conflict - Muscle name already exists' 76 | }, 77 | 500: { 78 | description: 'Internal server error' 79 | } 80 | } 81 | }), 82 | async (ctx) => { 83 | try { 84 | const body = await ctx.req.json() 85 | 86 | const response = await this.muscleService.createMuscle(body) 87 | 88 | return ctx.json({ success: true, data: [response] }, 201) 89 | } catch (error) { 90 | console.error('Error in createMuscle:', error) 91 | if (error instanceof HTTPException) { 92 | return ctx.json({ success: false, error: error.message }, error.status) 93 | } 94 | return ctx.json({ success: false, error: 'Internal Server Error' }, 500) 95 | } 96 | } 97 | ) 98 | this.controller.openapi( 99 | createRoute({ 100 | method: 'get', 101 | path: '/muscles', 102 | tags: ['Muscles'], 103 | summary: 'Retrive all muscles.', 104 | description: 'Retrive list of all the muscles.', 105 | operationId: 'getMuscles', 106 | responses: { 107 | 200: { 108 | description: 'Successful response with list of all muscles.', 109 | content: { 110 | 'application/json': { 111 | schema: z.object({ 112 | success: z.boolean().openapi({ 113 | description: 'Indicates whether the request was successful', 114 | type: 'boolean', 115 | example: true 116 | }), 117 | data: z.array(MuscleModel).openapi({ 118 | description: 'Array of Muslces.' 119 | }) 120 | }) 121 | } 122 | } 123 | }, 124 | 500: { 125 | description: 'Internal server error' 126 | } 127 | } 128 | }), 129 | async (ctx) => { 130 | const response = await this.muscleService.getMuscles() 131 | return ctx.json({ success: true, data: response }) 132 | } 133 | ) 134 | 135 | this.controller.openapi( 136 | createRoute({ 137 | method: 'get', 138 | path: '/muscles/{muscleName}/exercises', 139 | tags: ['Muscles'], 140 | summary: 'Retrive exercises by muscles', 141 | description: 'Retrive list of exercises by targetMuscles or secondaryMuscles.', 142 | operationId: 'getExercisesByEquipment', 143 | request: { 144 | params: z.object({ 145 | muscleName: z.string().openapi({ 146 | description: 'muscles name', 147 | type: 'string', 148 | example: 'upper back', 149 | default: 'upper back' 150 | }) 151 | }), 152 | query: z.object({ 153 | offset: z.coerce.number().nonnegative().optional().openapi({ 154 | title: 'Offset', 155 | description: 156 | 'The number of exercises to skip from the start of the list. Useful for pagination to fetch subsequent pages of results.', 157 | type: 'number', 158 | example: 10, 159 | default: 0 160 | }), 161 | limit: z.coerce.number().positive().max(100).optional().openapi({ 162 | title: 'Limit', 163 | description: 164 | 'The maximum number of exercises to return in the response. Limits the number of results for pagination purposes.', 165 | maximum: 100, 166 | minimum: 1, 167 | type: 'number', 168 | example: 10, 169 | default: 10 170 | }) 171 | }) 172 | }, 173 | responses: { 174 | 200: { 175 | description: 'Successful response with list of all exercises.', 176 | content: { 177 | 'application/json': { 178 | schema: z.object({ 179 | success: z.boolean().openapi({ 180 | description: 'Indicates whether the request was successful', 181 | type: 'boolean', 182 | example: true 183 | }), 184 | data: z.array(ExerciseModel).openapi({ 185 | description: 'Array of Exercises.' 186 | }) 187 | }) 188 | } 189 | } 190 | }, 191 | 500: { 192 | description: 'Internal server error' 193 | } 194 | } 195 | }), 196 | async (ctx) => { 197 | const { offset, limit = 10 } = ctx.req.valid('query') 198 | const search = ctx.req.param('muscleName') 199 | const { origin, pathname } = new URL(ctx.req.url) 200 | const response = await this.muscleService.getExercisesByMuscles({ offset, limit, search }) 201 | return ctx.json({ 202 | success: true, 203 | data: { 204 | previousPage: 205 | response.currentPage > 1 206 | ? `${origin}${pathname}?offset=${(response.currentPage - 1) * limit}&limit=${limit}` 207 | : null, 208 | nextPage: 209 | response.currentPage < response.totalPages 210 | ? `${origin}${pathname}?offset=${response.currentPage * limit}&limit=${limit}` 211 | : null, 212 | ...response 213 | } 214 | }) 215 | } 216 | ) 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/modules/muscles/models/muscle.model.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | export const MuscleModel = z.object({ 3 | name: z.string() 4 | }) 5 | -------------------------------------------------------------------------------- /src/modules/muscles/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './muscle.service' 2 | -------------------------------------------------------------------------------- /src/modules/muscles/services/muscle.service.ts: -------------------------------------------------------------------------------- 1 | import { IExerciseModel } from '#infra/mongodb/models/exercises/exercise.entity.js' 2 | import { IMuscleModel } from '#infra/mongodb/models/muscles/muscle.entity.js' 3 | import { GetExerciseSerivceArgs } from '#modules/exercises/services/exercise.service.js' 4 | import { 5 | GetExercisesArgs, 6 | GetExercisesUseCase 7 | } from '#modules/exercises/use-cases/get-exercises/get-exercise.usecase.js' 8 | import { CreateMuscleArgs, CreateMuscleUseCase } from '../use-cases/create-muscle' 9 | import { GetMusclesUseCase } from '../use-cases/get-muscles' 10 | 11 | export class MuscleService { 12 | private readonly createMuscleUseCase: CreateMuscleUseCase 13 | private readonly getMuscleUseCase: GetMusclesUseCase 14 | private readonly getExercisesUseCase: GetExercisesUseCase 15 | 16 | constructor( 17 | private readonly muscleModel: IMuscleModel, 18 | private readonly exerciseModel: IExerciseModel 19 | ) { 20 | this.createMuscleUseCase = new CreateMuscleUseCase(muscleModel) 21 | this.getMuscleUseCase = new GetMusclesUseCase(muscleModel) 22 | this.getExercisesUseCase = new GetExercisesUseCase(exerciseModel) 23 | } 24 | 25 | createMuscle = (args: CreateMuscleArgs) => { 26 | return this.createMuscleUseCase.execute(args) 27 | } 28 | 29 | getMuscles = () => { 30 | return this.getMuscleUseCase.execute() 31 | } 32 | getExercisesByMuscles = (params: GetExerciseSerivceArgs) => { 33 | const query: GetExercisesArgs = { 34 | offset: params.offset, 35 | limit: params.limit, 36 | query: { 37 | targetMuscles: { 38 | $all: [params.search] 39 | } 40 | } 41 | } 42 | 43 | return this.getExercisesUseCase.execute(query) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/modules/muscles/use-cases/create-muscle/create-muscle.use-case.ts: -------------------------------------------------------------------------------- 1 | import { IUseCase } from '#common/types/use-case.type.js' 2 | import { IMuscleDoc, IMuscleModel } from '#infra/mongodb/models/muscles/muscle.entity.js' 3 | import { HTTPException } from 'hono/http-exception' 4 | 5 | export interface CreateMuscleArgs { 6 | name: string 7 | } 8 | 9 | export class CreateMuscleUseCase implements IUseCase { 10 | constructor(private readonly muscleModel: IMuscleModel) {} 11 | 12 | async execute({ name }: CreateMuscleArgs): Promise { 13 | await this.checkIfMuscleExists(name) 14 | return this.createMuscle(name) 15 | } 16 | 17 | private async checkIfMuscleExists(name: string): Promise { 18 | if (await this.muscleModel.isMuscleExist(name)) { 19 | throw new HTTPException(409, { message: 'Muscle name already exists' }) 20 | } 21 | } 22 | 23 | private async createMuscle(name: string): Promise { 24 | return this.muscleModel.create({ name }) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/modules/muscles/use-cases/create-muscle/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-muscle.use-case' 2 | -------------------------------------------------------------------------------- /src/modules/muscles/use-cases/get-muscles/get-muscle.usecase.ts: -------------------------------------------------------------------------------- 1 | import { IUseCase } from '#common/types/use-case.type.js' 2 | import { IMuscleDoc, IMuscleModel } from '#infra/mongodb/models/muscles/muscle.entity.js' 3 | 4 | export class GetMusclesUseCase implements IUseCase { 5 | constructor(private readonly muscleModel: IMuscleModel) {} 6 | 7 | async execute(): Promise { 8 | return this.muscleModel.find({}) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/modules/muscles/use-cases/get-muscles/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-muscle.usecase' 2 | -------------------------------------------------------------------------------- /src/modules/users/controllers/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cyberboyanmol/exercisedb-api/c9c34b7124de25bfe9758f90005c76b513dbe738/src/modules/users/controllers/index.ts -------------------------------------------------------------------------------- /src/modules/users/controllers/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '#common/types/route.type.js' 2 | import { createRoute, OpenAPIHono } from '@hono/zod-openapi' 3 | import { z } from 'zod' 4 | import { HTTPException } from 'hono/http-exception' 5 | import { UserService } from '../services' 6 | import User from '#infra/mongodb/models/users/user.schema.js' 7 | import { IUserDoc } from '#infra/mongodb/models/users/user.entity' 8 | export class UserController implements Routes { 9 | public controller: OpenAPIHono 10 | private readonly userService: UserService 11 | constructor() { 12 | this.controller = new OpenAPIHono() 13 | this.userService = new UserService(User) 14 | } 15 | 16 | public initRoutes() { 17 | this.controller.openapi( 18 | createRoute({ 19 | method: 'post', 20 | path: '/register', 21 | tags: ['Users'], 22 | summary: 'register users endpoint ', 23 | description: 'This route is used to register user account.', 24 | operationId: 'registerUser', 25 | request: { 26 | body: { 27 | content: { 28 | 'application/json': { 29 | schema: z.object({ 30 | email: z.string().email().openapi({ 31 | title: 'Email', 32 | description: 'Email for account register', 33 | type: 'string', 34 | example: 'johndoe@example.com' 35 | }) 36 | }) 37 | } 38 | } 39 | } 40 | }, 41 | responses: { 42 | 201: { 43 | description: 'User register successfully.', 44 | content: { 45 | 'application/json': { 46 | schema: z.object({ 47 | success: z.boolean().openapi({ 48 | description: 'Indicates whether the request was successful', 49 | type: 'boolean', 50 | example: true 51 | }), 52 | data: z 53 | .object({ 54 | id: z.string().openapi({ 55 | title: 'user Id', 56 | description: 'user unique identifier' 57 | }), 58 | email: z.string().openapi({ 59 | title: 'user email', 60 | description: 'user email address' 61 | }), 62 | role: z.string().openapi({ 63 | title: 'user role', 64 | description: 'user active role' 65 | }), 66 | isActivated: z.string().openapi({ 67 | title: 'activation status', 68 | description: 'user account activation status' 69 | }), 70 | otpSecret: z.string().openapi({ 71 | title: 'Otp Secret', 72 | description: 'otp secret for adding in authenticator app' 73 | }) 74 | }) 75 | .openapi({ 76 | title: 'User Data', 77 | description: 'The newly registered user' 78 | }) 79 | }) 80 | } 81 | } 82 | }, 83 | 400: { 84 | description: 'Bad request - Invalid input data', 85 | content: { 86 | 'application/json': { 87 | schema: z.object({ 88 | success: z.boolean(), 89 | error: z.string() 90 | }) 91 | } 92 | } 93 | }, 94 | 409: { 95 | description: 'Conflict - email already exists' 96 | }, 97 | 500: { 98 | description: 'Internal server error' 99 | } 100 | } 101 | }), 102 | async (ctx) => { 103 | try { 104 | const email = (await ctx.req.json()).email 105 | 106 | const response = await this.userService.createUser({ email }) 107 | return ctx.json({ success: true, data: response }, 201) 108 | } catch (error) { 109 | console.error('Error in creating user:', error) 110 | if (error instanceof HTTPException) { 111 | return ctx.json({ success: false, error: error.message }, error.status) 112 | } 113 | return ctx.json({ success: false, error: 'Internal Server Error' }, 500) 114 | } 115 | } 116 | ) 117 | 118 | this.controller.openapi( 119 | createRoute({ 120 | method: 'post', 121 | path: '/authenticate', 122 | tags: ['Users'], 123 | summary: 'User Authentication', 124 | description: 'This route is used to authenticate user based on email and authenticator code.', 125 | operationId: 'authenticateUser', 126 | request: { 127 | body: { 128 | content: { 129 | 'application/json': { 130 | schema: z.object({ 131 | email: z.string().email().openapi({ 132 | title: 'Email', 133 | description: 'user registered email address', 134 | type: 'string', 135 | example: 'johndoe@example.com' 136 | }), 137 | code: z.string().openapi({ 138 | title: 'Authenticator code', 139 | description: 'code generated by Authenticator app ', 140 | type: 'string', 141 | example: '6XX5XX' 142 | }) 143 | }) 144 | } 145 | } 146 | } 147 | }, 148 | responses: { 149 | 200: { 150 | description: 'User authenticated successfully.', 151 | content: { 152 | 'application/json': { 153 | schema: z.object({ 154 | success: z.boolean().openapi({ 155 | description: 'Indicates whether the authentication request was successful', 156 | type: 'boolean', 157 | example: true 158 | }), 159 | data: z 160 | .object({ 161 | accessToken: z.string().openapi({ 162 | title: 'Access token', 163 | description: 'access token for making post requests' 164 | }) 165 | }) 166 | .openapi({ 167 | title: 'Access Token', 168 | description: 'new access token' 169 | }) 170 | }) 171 | } 172 | } 173 | }, 174 | 400: { 175 | description: 'Bad request - Invalid input data', 176 | content: { 177 | 'application/json': { 178 | schema: z.object({ 179 | success: z.boolean(), 180 | error: z.string() 181 | }) 182 | } 183 | } 184 | }, 185 | 500: { 186 | description: 'Internal server error' 187 | } 188 | } 189 | }), 190 | async (ctx) => { 191 | try { 192 | const email = (await ctx.req.json()).email 193 | const code = (await ctx.req.json()).code 194 | 195 | const response = await this.userService.authenticate({ email, code }) 196 | return ctx.json({ success: true, data: response }, 200) 197 | } catch (error) { 198 | console.error('Error in authenticating user:', error) 199 | if (error instanceof HTTPException) { 200 | return ctx.json({ success: false, error: error.message }, error.status) 201 | } 202 | return ctx.json({ success: false, error: 'Internal Server Error' }, 500) 203 | } 204 | } 205 | ) 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/modules/users/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user.service' 2 | -------------------------------------------------------------------------------- /src/modules/users/services/user.service.ts: -------------------------------------------------------------------------------- 1 | import { CryptoService } from '#common/helpers/crypto.service.js' 2 | import { IUserModel } from '#infra/mongodb/models/users/user.entity.js' 3 | import { AuthenticateUserArgs, AuthenticateUserUseCase } from '../use-cases/authenticate' 4 | import { CreateUserArgs, CreateUserUseCase } from '../use-cases/create-user' 5 | import * as jwt from 'jsonwebtoken' 6 | export class UserService { 7 | private readonly authenticateUserUseCase: AuthenticateUserUseCase 8 | private readonly createUserUseCase: CreateUserUseCase 9 | private readonly cryptoService: CryptoService 10 | 11 | constructor(private readonly userModel: IUserModel) { 12 | this.authenticateUserUseCase = new AuthenticateUserUseCase(userModel) 13 | this.createUserUseCase = new CreateUserUseCase(userModel) 14 | this.cryptoService = new CryptoService() 15 | } 16 | 17 | createUser = (args: CreateUserArgs) => { 18 | const newUser = this.createUserUseCase.execute(args) 19 | return newUser 20 | } 21 | 22 | authenticate = async (args: AuthenticateUserArgs) => { 23 | const user = await this.authenticateUserUseCase.execute(args) 24 | const token = this.cryptoService.generateAccessToken(user) 25 | return { 26 | token 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/modules/users/use-cases/authenticate/authenticate.usecase.ts: -------------------------------------------------------------------------------- 1 | import { IUseCase } from '#common/types/use-case.type.js' 2 | import { IUserDoc, IUserModel } from '#infra/mongodb/models/users/user.entity.js' 3 | import { HTTPException } from 'hono/http-exception' 4 | import { authenticator } from 'otplib' 5 | import { GetUserUseCase } from '../get-user' 6 | import { CryptoService } from '#common/helpers/crypto.service.js' 7 | 8 | export interface AuthenticateUserArgs { 9 | email: string 10 | code: string 11 | } 12 | 13 | export class AuthenticateUserUseCase implements IUseCase { 14 | private readonly getUserUseCase: GetUserUseCase 15 | private readonly cryptoService: CryptoService 16 | constructor(private readonly userModel: IUserModel) { 17 | this.getUserUseCase = new GetUserUseCase(userModel) 18 | this.cryptoService = new CryptoService() 19 | } 20 | 21 | async execute({ email, code }: AuthenticateUserArgs): Promise { 22 | const user = await this.getUserUseCase.execute({ email }) 23 | if (!user) { 24 | throw new HTTPException(404, { message: 'user not found' }) 25 | } 26 | 27 | const isValid = this.cryptoService.verifyCode(user.otpSecret, code) 28 | if (!isValid) throw new HTTPException(400, { message: 'invalid code' }) 29 | 30 | if (!user.isActivated) { 31 | await this.activateUser(user) 32 | } 33 | return user 34 | } 35 | 36 | private async activateUser(user: IUserDoc): Promise { 37 | return this.userModel.findByIdAndUpdate(user.id, { isActivated: true }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/modules/users/use-cases/authenticate/index.ts: -------------------------------------------------------------------------------- 1 | export * from './authenticate.usecase' 2 | -------------------------------------------------------------------------------- /src/modules/users/use-cases/create-user/create-user.usercase.ts: -------------------------------------------------------------------------------- 1 | import { CryptoService } from '#common/helpers/crypto.service.js' 2 | import { IUseCase } from '#common/types/use-case.type.js' 3 | import { IUserDoc, IUserModel } from '#infra/mongodb/models/users/user.entity.js' 4 | import { HTTPException } from 'hono/http-exception' 5 | import { authenticator } from 'otplib' 6 | 7 | export interface CreateUserArgs { 8 | email: string 9 | } 10 | 11 | export class CreateUserUseCase implements IUseCase { 12 | private readonly cryptoService: CryptoService 13 | constructor(private readonly userModel: IUserModel) { 14 | this.cryptoService = new CryptoService() 15 | } 16 | 17 | async execute({ email }: CreateUserArgs): Promise { 18 | await this.checkIfUserExists(email) 19 | const otpSecret = this.cryptoService.generateOtpSecret() 20 | return this.createUser(email, otpSecret) 21 | } 22 | 23 | public async checkIfUserExists(email: string): Promise { 24 | if (await this.userModel.isEmailExist(email)) { 25 | throw new HTTPException(409, { message: 'Email already exists' }) 26 | } 27 | } 28 | 29 | private async createUser(email: string, otpSecret: string): Promise { 30 | return this.userModel.create({ email, otpSecret }) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/modules/users/use-cases/create-user/index.ts: -------------------------------------------------------------------------------- 1 | export * from './create-user.usercase' 2 | -------------------------------------------------------------------------------- /src/modules/users/use-cases/get-user/get-user.usecase.ts: -------------------------------------------------------------------------------- 1 | import { IUseCase } from '#common/types/use-case.type.js' 2 | import { IUserDoc, IUserModel } from '#infra/mongodb/models/users/user.entity.js' 3 | import { HTTPException } from 'hono/http-exception' 4 | import { authenticator } from 'otplib' 5 | import { CreateUserUseCase } from '../create-user' 6 | 7 | export interface GetUserArgs { 8 | email: string 9 | } 10 | 11 | export class GetUserUseCase implements IUseCase { 12 | constructor(private readonly userModel: IUserModel) {} 13 | 14 | async execute({ email }: GetUserArgs): Promise { 15 | return this.userModel.findOne({ email }) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/modules/users/use-cases/get-user/index.ts: -------------------------------------------------------------------------------- 1 | export * from './get-user.usecase' 2 | -------------------------------------------------------------------------------- /src/pages/home.tsx: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | 3 | export const Home = new Hono() 4 | 5 | export const Meteors = ({ number }: { number: number }) => { 6 | return ( 7 | <> 8 | {Array.from({ length: number || 30 }, (_, idx) => ( 9 | 19 | ))} 20 | 21 | ) 22 | } 23 | 24 | Home.get('/', (c) => { 25 | const title = 'ExerciseDB API' 26 | const description = 27 | 'Access detailed data on over 1300+ exercises with the ExerciseDB API. This API offers extensive information on each exercise, including target body parts, equipment needed, GIFs for visual guidance, and step-by-step instructions.' 28 | return c.html( 29 | 30 | 31 | ExerciseDB API 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 52 |