Skip to content

Commit 252fedb

Browse files
Add generic route access control, apply it to users route, and update README.md
2 parents c7e94f3 + e5eac30 commit 252fedb

8 files changed

Lines changed: 625 additions & 142 deletions

File tree

.env.test

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,6 @@ ADMIN_SECRET=admin_secret
66

77
SECRET=secret
88

9-
TOKEN_EXP_PERIOD=3d
9+
TOKEN_EXP_PERIOD=3d
10+
11+
ALLOWED_ORIGINS=*

README.md

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,90 @@
11
# Generic Express Service
22

3-
A generic Express.js back-end service that supports multiple front-end projects. Includes RESTful API endpoints, authentication/authorization logic, and maybe some scheduled background jobs for database maintenance.
3+
A generic Express.js back-end service designed to support multiple front-end apps that I am managing to build in parallel with this app. It includes RESTful API endpoints and user authentication and utilizes PostgreSQL for data persistence.
4+
5+
## Features
6+
7+
- RESTful API with various endpoints serving different purposes
8+
- User management and authentication endpoints
9+
- Local PostgreSQL integration via Docker Compose
10+
- TypeScript support with `tsx` for development
11+
- Environment-based configuration
12+
- Tested using Vitest and Supertest
13+
- Ready for integration with front-end applications
14+
15+
## Getting Started
16+
17+
### Prerequisites
18+
19+
- [Node.js](https://nodejs.org/) (tested on v22 but v20 should be fine too)
20+
- [Docker](https://www.docker.com/) and [Docker Compose](https://docs.docker.com/compose/)
21+
22+
## Installation
23+
24+
1. **Clone the repository:**
25+
26+
```bash
27+
git clone https://github.com/hussein-m-kandil/generic-express-service.git
28+
cd generic-express-service
29+
```
30+
31+
2. **Install dependencies:**
32+
33+
```bash
34+
npm install
35+
```
36+
37+
3. **Set up the environment variables:**
38+
39+
```bash
40+
cp .env.test .env
41+
# Then edit `.env` to fit your local setup
42+
```
43+
44+
4. **Start the PostgreSQL service:**
45+
46+
```bash
47+
npm run pg:up
48+
```
49+
50+
5. **Push the Prisma schema to the database:**
51+
52+
```bash
53+
npx prisma db push
54+
```
55+
56+
6. **Start the development server:**
57+
58+
```bash
59+
npm run dev
60+
```
61+
62+
The API will be available at `http://localhost:8080`.
63+
64+
## Running all tests
65+
66+
```bash
67+
npm run test -- --run
68+
```
69+
70+
## Deployment
71+
72+
Every _push_ or _pull request (PR)_ on main branch, the app will be deployed to production automatically _if it passes all tests and checks performed by [a GitHub action for deployment preparation](./.github/workflows/deployment-prep.yml)_.
73+
74+
## Notes
75+
76+
- This service is intended to support multiple front-end projects that I build.
77+
- CORS is configured to allow only specific front-end origins under my control.
78+
- JWT-based authentication is implemented and required by some endpoint.
79+
- The `Bearer` schema is included in the authentication response, so _send your token as it is_ via an `Authorization` header.
80+
- All error responses has the proper status code, but not all of them has a body. If an error response has a body it will have _at least_ the following:
81+
82+
```json
83+
{
84+
"error": {
85+
"message": "An example error"
86+
}
87+
}
88+
```
89+
90+
- A validation error response body will have the form of _[ZodError.issues](https://zod.dev/?id=error-handling)._

requests.rest

Lines changed: 114 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,48 @@
1+
# Admin token
12
GET http://127.0.0.1:8080/api/v1/users
3+
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjkzZWE2NjE5LTIyMWEtNDkwYi1iYjcwLWZmZWM0NWQ5MjAyNSIsInVzZXJuYW1lIjoiYWRtaW4iLCJmdWxsbmFtZSI6IkFkbWluIiwiaWF0IjoxNzQ2NDQ2MDA5LCJleHAiOjE3NDY3MDUyMDl9.kIMRW1ctklvIDJf8p_TEMAODHcn3D8HBC6lAkAFDVMs
24

35
###
4-
GET http://127.0.0.1:8080/api/v1/users/7d517973-7b72-4306-8118-c54021db0585
6+
7+
# Normal user token
8+
GET http://127.0.0.1:8080/api/v1/users
9+
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjZjNjU1ZDIwLWUyN2QtNDJiMS1hOTNkLWEyYTQxNTA4NzZiZiIsInVzZXJuYW1lIjoibm93aGVyZV9tYW4iLCJmdWxsbmFtZSI6Ik5vd2hlcmUtTWFuIiwiaWF0IjoxNzQ2NDUyMzQ5LCJleHAiOjE3NDY3MTE1NDl9.0lGUkEygKSB5z7RBbKGrP8kY_YeUaoYNISGB9QLWcbc
10+
11+
###
12+
13+
# Admin token
14+
GET http://127.0.0.1:8080/api/v1/users/6c655d20-e27d-42b1-a93d-a2a4150876bf
15+
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjkzZWE2NjE5LTIyMWEtNDkwYi1iYjcwLWZmZWM0NWQ5MjAyNSIsInVzZXJuYW1lIjoiYWRtaW4iLCJmdWxsbmFtZSI6IkFkbWluIiwiaWF0IjoxNzQ2NDQ2MDA5LCJleHAiOjE3NDY3MDUyMDl9.kIMRW1ctklvIDJf8p_TEMAODHcn3D8HBC6lAkAFDVMs
516

617
###
18+
19+
# Another normal user token
20+
GET http://127.0.0.1:8080/api/v1/users/93ea6619-221a-490b-bb70-ffec45d92025
21+
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjZjNjU1ZDIwLWUyN2QtNDJiMS1hOTNkLWEyYTQxNTA4NzZiZiIsInVzZXJuYW1lIjoibm93aGVyZV9tYW4iLCJmdWxsbmFtZSI6Ik5vd2hlcmUtTWFuIiwiaWF0IjoxNzQ2NDUyMzQ5LCJleHAiOjE3NDY3MTE1NDl9.0lGUkEygKSB5z7RBbKGrP8kY_YeUaoYNISGB9QLWcbc
22+
23+
###
24+
25+
# Same user token
26+
GET http://127.0.0.1:8080/api/v1/users/6c655d20-e27d-42b1-a93d-a2a4150876bf
27+
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjZjNjU1ZDIwLWUyN2QtNDJiMS1hOTNkLWEyYTQxNTA4NzZiZiIsInVzZXJuYW1lIjoibm93aGVyZV9tYW4iLCJmdWxsbmFtZSI6Ik5vd2hlcmUtTWFuIiwiaWF0IjoxNzQ2NDUyMzQ5LCJleHAiOjE3NDY3MTE1NDl9.0lGUkEygKSB5z7RBbKGrP8kY_YeUaoYNISGB9QLWcbc
28+
29+
###
30+
31+
# Admin
32+
POST http://127.0.0.1:8080/api/v1/users
33+
Content-Type: application/json
34+
35+
{
36+
"username": "admin",
37+
"fullname": "Admin",
38+
"password": "Aa@12312",
39+
"confirm": "Aa@12312",
40+
"secret": "seco_seco"
41+
}
42+
43+
###
44+
45+
# Normal user
746
POST http://127.0.0.1:8080/api/v1/users
847
Content-Type: application/json
948

@@ -15,15 +54,21 @@ Content-Type: application/json
1554
}
1655

1756
###
18-
PATCH http://127.0.0.1:8080/api/v1/users/7d517973-7b72-4306-8118-c54021db0585
57+
58+
# Admin token
59+
PATCH http://127.0.0.1:8080/api/v1/users/6c655d20-e27d-42b1-a93d-a2a4150876bf
60+
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjkzZWE2NjE5LTIyMWEtNDkwYi1iYjcwLWZmZWM0NWQ5MjAyNSIsInVzZXJuYW1lIjoiYWRtaW4iLCJmdWxsbmFtZSI6IkFkbWluIiwiaWF0IjoxNzQ2NDQ2MDA5LCJleHAiOjE3NDY3MDUyMDl9.kIMRW1ctklvIDJf8p_TEMAODHcn3D8HBC6lAkAFDVMs
1961
Content-Type: application/json
2062

2163
{
2264
"username": "somewhere_man"
2365
}
2466

2567
###
26-
PATCH http://127.0.0.1:8080/api/v1/users/7d517973-7b72-4306-8118-c54021db0585
68+
69+
# Admin token
70+
PATCH http://127.0.0.1:8080/api/v1/users/6c655d20-e27d-42b1-a93d-a2a4150876bf
71+
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjkzZWE2NjE5LTIyMWEtNDkwYi1iYjcwLWZmZWM0NWQ5MjAyNSIsInVzZXJuYW1lIjoiYWRtaW4iLCJmdWxsbmFtZSI6IkFkbWluIiwiaWF0IjoxNzQ2NDQ2MDA5LCJleHAiOjE3NDY3MDUyMDl9.kIMRW1ctklvIDJf8p_TEMAODHcn3D8HBC6lAkAFDVMs
2772
Content-Type: application/json
2873

2974
{
@@ -32,13 +77,75 @@ Content-Type: application/json
3277
}
3378

3479
###
35-
POST http://127.0.0.1:8080/api/v1/auth/signin
80+
81+
# Same user token
82+
PATCH http://127.0.0.1:8080/api/v1/users/6c655d20-e27d-42b1-a93d-a2a4150876bf
83+
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjZjNjU1ZDIwLWUyN2QtNDJiMS1hOTNkLWEyYTQxNTA4NzZiZiIsInVzZXJuYW1lIjoibm93aGVyZV9tYW4iLCJmdWxsbmFtZSI6Ik5vd2hlcmUtTWFuIiwiaWF0IjoxNzQ2NDUyMzQ5LCJleHAiOjE3NDY3MTE1NDl9.0lGUkEygKSB5z7RBbKGrP8kY_YeUaoYNISGB9QLWcbc
84+
Content-Type: application/json
85+
86+
{
87+
"username": "somewhere_man"
88+
}
89+
90+
###
91+
92+
# Same user token
93+
PATCH http://127.0.0.1:8080/api/v1/users/6c655d20-e27d-42b1-a93d-a2a4150876bf
94+
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjZjNjU1ZDIwLWUyN2QtNDJiMS1hOTNkLWEyYTQxNTA4NzZiZiIsInVzZXJuYW1lIjoibm93aGVyZV9tYW4iLCJmdWxsbmFtZSI6Ik5vd2hlcmUtTWFuIiwiaWF0IjoxNzQ2NDUyMzQ5LCJleHAiOjE3NDY3MTE1NDl9.0lGUkEygKSB5z7RBbKGrP8kY_YeUaoYNISGB9QLWcbc
95+
Content-Type: application/json
96+
97+
{
98+
"password": "Sm@12312",
99+
"confirm": "Sm@12312"
100+
}
101+
102+
###
103+
104+
# Another user token
105+
PATCH http://127.0.0.1:8080/api/v1/users/93ea6619-221a-490b-bb70-ffec45d92025
106+
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjZjNjU1ZDIwLWUyN2QtNDJiMS1hOTNkLWEyYTQxNTA4NzZiZiIsInVzZXJuYW1lIjoibm93aGVyZV9tYW4iLCJmdWxsbmFtZSI6Ik5vd2hlcmUtTWFuIiwiaWF0IjoxNzQ2NDUyMzQ5LCJleHAiOjE3NDY3MTE1NDl9.0lGUkEygKSB5z7RBbKGrP8kY_YeUaoYNISGB9QLWcbc
36107
Content-Type: application/json
37108

38109
{
39-
"username": "somewhere_man",
40-
"password": "Sm@12312"
110+
"username": "somewhere_man"
41111
}
42112

113+
###
114+
115+
# Another user token
116+
PATCH http://127.0.0.1:8080/api/v1/users/93ea6619-221a-490b-bb70-ffec45d92025
117+
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjZjNjU1ZDIwLWUyN2QtNDJiMS1hOTNkLWEyYTQxNTA4NzZiZiIsInVzZXJuYW1lIjoibm93aGVyZV9tYW4iLCJmdWxsbmFtZSI6Ik5vd2hlcmUtTWFuIiwiaWF0IjoxNzQ2NDUyMzQ5LCJleHAiOjE3NDY3MTE1NDl9.0lGUkEygKSB5z7RBbKGrP8kY_YeUaoYNISGB9QLWcbc
118+
Content-Type: application/json
119+
120+
{
121+
"password": "Sm@12312",
122+
"confirm": "Sm@12312"
123+
}
124+
125+
###
126+
127+
# Admin token
128+
DELETE http://127.0.0.1:8080/api/v1/users/7d517973-7b72-4306-8118-c54021db0585
129+
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjkzZWE2NjE5LTIyMWEtNDkwYi1iYjcwLWZmZWM0NWQ5MjAyNSIsInVzZXJuYW1lIjoiYWRtaW4iLCJmdWxsbmFtZSI6IkFkbWluIiwiaWF0IjoxNzQ2NDQ2MDA5LCJleHAiOjE3NDY3MDUyMDl9.kIMRW1ctklvIDJf8p_TEMAODHcn3D8HBC6lAkAFDVMs
130+
43131
###
44-
DELETE http://127.0.0.1:8080/api/v1/users/7d517973-7b72-4306-8118-c54021db0585
132+
133+
# Same user token
134+
DELETE http://127.0.0.1:8080/api/v1/users/6c655d20-e27d-42b1-a93d-a2a4150876bf
135+
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjZjNjU1ZDIwLWUyN2QtNDJiMS1hOTNkLWEyYTQxNTA4NzZiZiIsInVzZXJuYW1lIjoibm93aGVyZV9tYW4iLCJmdWxsbmFtZSI6Ik5vd2hlcmUtTWFuIiwiaWF0IjoxNzQ2NDUyMzQ5LCJleHAiOjE3NDY3MTE1NDl9.0lGUkEygKSB5z7RBbKGrP8kY_YeUaoYNISGB9QLWcbc
136+
137+
###
138+
139+
# Another user token
140+
DELETE http://127.0.0.1:8080/api/v1/users/93ea6619-221a-490b-bb70-ffec45d92025
141+
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjZjNjU1ZDIwLWUyN2QtNDJiMS1hOTNkLWEyYTQxNTA4NzZiZiIsInVzZXJuYW1lIjoibm93aGVyZV9tYW4iLCJmdWxsbmFtZSI6Ik5vd2hlcmUtTWFuIiwiaWF0IjoxNzQ2NDUyMzQ5LCJleHAiOjE3NDY3MTE1NDl9.0lGUkEygKSB5z7RBbKGrP8kY_YeUaoYNISGB9QLWcbc
142+
143+
###
144+
145+
POST http://127.0.0.1:8080/api/v1/auth/signin
146+
Content-Type: application/json
147+
148+
{
149+
"username": "nowhere_man",
150+
"password": "Nm@12312"
151+
}

src/api/v1/auth/auth.router.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { RequestHandler, Router } from 'express';
88
import { AuthResponse } from '../../../types';
99
import logger from '../../../lib/logger';
1010
import passport from '../../../lib/passport';
11-
import { authValidator } from '../../../middlewares/auth-validator';
11+
import { authValidator } from '../../../middlewares/validators';
1212

1313
export const authRouter = Router();
1414

src/api/v1/users/users.router.ts

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { Request, Router } from 'express';
2-
import { AuthResponse, NewUserInput } from '../../../types';
32
import { createJwtForUser } from '../../../lib/helpers';
43
import { AppNotFoundError } from '../../../lib/app-error';
4+
import { AuthResponse, NewUserInput } from '../../../types';
55
import { Prisma } from '../../../../prisma/generated/client';
6+
import {
7+
authValidator,
8+
adminValidator,
9+
createAdminOrOwnerValidator,
10+
} from '../../../middlewares/validators';
611
import userSchema, {
712
usernameSchema,
813
fullnameSchema,
@@ -13,16 +18,21 @@ import usersService from './users.service';
1318

1419
export const usersRouter = Router();
1520

16-
usersRouter.get('/', async (req, res) => {
21+
usersRouter.get('/', authValidator, adminValidator, async (req, res) => {
1722
const users = await usersService.getAll();
1823
res.json(users);
1924
});
2025

21-
usersRouter.get('/:id', async (req, res) => {
22-
const user = await usersService.findOneById(req.params.id);
23-
if (!user) throw new AppNotFoundError('User not found');
24-
res.json(user);
25-
});
26+
usersRouter.get(
27+
'/:id',
28+
authValidator,
29+
createAdminOrOwnerValidator((req) => req.params.id),
30+
async (req, res) => {
31+
const user = await usersService.findOneById(req.params.id);
32+
if (!user) throw new AppNotFoundError('User not found');
33+
res.json(user);
34+
}
35+
);
2636

2737
usersRouter.post('/', async (req, res) => {
2838
const parsedNewUser = userSchema.parse(req.body);
@@ -36,6 +46,8 @@ usersRouter.post('/', async (req, res) => {
3646

3747
usersRouter.patch(
3848
'/:id',
49+
authValidator,
50+
createAdminOrOwnerValidator((req) => req.params.id),
3951
async (req: Request<{ id: string }, unknown, NewUserInput>, res) => {
4052
const { username, fullname, password, confirm, secret } = req.body;
4153
const data: Prisma.UserUpdateInput = {};
@@ -53,9 +65,14 @@ usersRouter.patch(
5365
}
5466
);
5567

56-
usersRouter.delete('/:id', async (req, res) => {
57-
await usersService.deleteOne(req.params.id);
58-
res.status(204).end();
59-
});
68+
usersRouter.delete(
69+
'/:id',
70+
authValidator,
71+
createAdminOrOwnerValidator((req) => req.params.id),
72+
async (req, res) => {
73+
await usersService.deleteOne(req.params.id);
74+
res.status(204).end();
75+
}
76+
);
6077

6178
export default usersRouter;

src/middlewares/auth-validator.ts

Lines changed: 0 additions & 6 deletions
This file was deleted.

0 commit comments

Comments
 (0)