create-simo-api 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -2
- package/index.js +318 -0
- package/package.json +24 -4
- package/template/README.md +61 -0
- package/template/_env.example +15 -0
- package/template/_gitignore +4 -0
- package/template/app.js +29 -0
- package/template/config/db.js +6 -0
- package/template/config/env.js +27 -0
- package/template/config/permissions.js +15 -0
- package/template/controllers/authController.js +53 -0
- package/template/controllers/productController.js +34 -0
- package/template/controllers/userController.js +22 -0
- package/template/index.js +6 -0
- package/template/middlewares/authMiddleware.js +15 -0
- package/template/middlewares/errorMiddleware.js +33 -0
- package/template/middlewares/rateLimitMiddleware.js +14 -0
- package/template/middlewares/roleMiddleware.js +22 -0
- package/template/middlewares/validateMiddleware.js +18 -0
- package/template/migrations/1792673478177_create-users-table.sql +16 -0
- package/template/migrations/1792842956065_create-refresh-tokens-table.sql +17 -0
- package/template/migrations/1792842957555_create-products-table.sql +14 -0
- package/template/models/productModel.js +42 -0
- package/template/models/refreshTokenModel.js +25 -0
- package/template/models/userModel.js +39 -0
- package/template/package.json +33 -0
- package/template/routes/authRoutes.js +13 -0
- package/template/routes/index.js +15 -0
- package/template/routes/productRoutes.js +17 -0
- package/template/routes/userRoutes.js +15 -0
- package/template/seeds/seed.js +50 -0
- package/template/services/authService.js +94 -0
- package/template/services/productService.js +37 -0
- package/template/services/userService.js +24 -0
- package/template/tests/api.test.js +174 -0
- package/template/utils/AppError.js +10 -0
- package/template/utils/verifyToken.js +12 -0
- package/template/validations/authValidation.js +18 -0
- package/template/validations/productValidation.js +21 -0
- package/template/validations/userValidation.js +10 -0
package/README.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
-
#
|
|
1
|
+
# create-simo-api
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Scaffold an Express 5 + PostgreSQL API pre-configured with:
|
|
4
|
+
|
|
5
|
+
- **JWT auth**: access token + refresh token in an `httpOnly` cookie, stored hashed, rotated on every refresh, reuse detection
|
|
6
|
+
- **Role permissions** with `authorize('PERMISSION')`
|
|
7
|
+
- **Zod** validation for request bodies, params and `.env`
|
|
8
|
+
- **Service layer** (routes → controllers → services → models)
|
|
9
|
+
- Central **error handling** (`AppError`, JSON 404, PostgreSQL errors)
|
|
10
|
+
- **Migrations** (`node-pg-migrate`), admin **seed**, **rate limiting**, **morgan** logs
|
|
11
|
+
- **Tests** with Supertest
|
|
12
|
+
- Example resources: users and products
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm create simo-api@latest my-api
|
|
18
|
+
cd my-api
|
|
19
|
+
# set DATABASE_URL in .env
|
|
20
|
+
npm run migrate:up
|
|
21
|
+
npm run seed
|
|
22
|
+
npm run dev
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`.env` is generated with random JWT secrets and a random admin password.
|
|
26
|
+
|
|
27
|
+
## Requirements
|
|
28
|
+
|
|
29
|
+
- Node.js 20+
|
|
30
|
+
- PostgreSQL
|
|
31
|
+
|
|
32
|
+
## License
|
|
33
|
+
|
|
34
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join, relative, resolve } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
6
|
+
import { randomBytes } from 'node:crypto'
|
|
7
|
+
import { styleText } from 'node:util'
|
|
8
|
+
import { createInterface } from 'node:readline/promises'
|
|
9
|
+
import { createRequire } from 'node:module'
|
|
10
|
+
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
12
|
+
const TEMPLATE_DIR = join(__dirname, 'template')
|
|
13
|
+
|
|
14
|
+
const RENAME = {
|
|
15
|
+
_gitignore: '.gitignore',
|
|
16
|
+
'_env.example': '.env.example',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
20
|
+
|
|
21
|
+
let createdDir = null
|
|
22
|
+
|
|
23
|
+
function removeProject() {
|
|
24
|
+
if (!createdDir) return
|
|
25
|
+
rmSync(createdDir, { recursive: true, force: true })
|
|
26
|
+
console.error(styleText('gray', ` Project folder removed: ${relative(process.cwd(), createdDir)}\n`))
|
|
27
|
+
createdDir = null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function fail(msg) {
|
|
31
|
+
console.error(`\n${styleText(['red', 'bold'], '✖')} ${styleText('red', msg)}\n`)
|
|
32
|
+
removeProject()
|
|
33
|
+
process.exit(1)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function cancel() {
|
|
37
|
+
console.log(`\n\n${styleText('gray', 'Cancelled.')}\n`)
|
|
38
|
+
removeProject()
|
|
39
|
+
process.exit(130)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
process.on('SIGINT', cancel)
|
|
43
|
+
|
|
44
|
+
function listFiles(dir) {
|
|
45
|
+
return readdirSync(dir, { recursive: true })
|
|
46
|
+
.map((file) => join(dir, file))
|
|
47
|
+
.filter((file) => statSync(file).isFile())
|
|
48
|
+
.sort()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function installDependencies(cwd) {
|
|
52
|
+
return new Promise((resolvePromise) => {
|
|
53
|
+
let frame = 0
|
|
54
|
+
const label = 'Installation in progress... ☕'
|
|
55
|
+
const timer = setInterval(() => {
|
|
56
|
+
process.stdout.write(`\r${styleText('cyan', SPINNER[frame++ % SPINNER.length])} ${label}`)
|
|
57
|
+
}, 80)
|
|
58
|
+
|
|
59
|
+
let output = ''
|
|
60
|
+
const child = spawn('npm', ['install'], { cwd })
|
|
61
|
+
child.stdout.on('data', (data) => { output += data })
|
|
62
|
+
child.stderr.on('data', (data) => { output += data })
|
|
63
|
+
|
|
64
|
+
child.on('close', (code) => {
|
|
65
|
+
clearInterval(timer)
|
|
66
|
+
const icon = code === 0 ? styleText('green', '✔') : styleText('red', '✖')
|
|
67
|
+
process.stdout.write(`\r${icon} ${label}\n`)
|
|
68
|
+
resolvePromise({ code, output })
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function runCommand(cwd, args) {
|
|
74
|
+
console.log(`\n${styleText('gray', '$')} ${styleText('cyan', `npm ${args.join(' ')}`)}`)
|
|
75
|
+
return new Promise((resolvePromise) => {
|
|
76
|
+
const child = spawn('npm', args, { cwd, stdio: 'inherit' })
|
|
77
|
+
child.on('close', (code) => resolvePromise(code))
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function ask(question) {
|
|
82
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
83
|
+
rl.on('SIGINT', cancel)
|
|
84
|
+
try {
|
|
85
|
+
const answer = await rl.question(question)
|
|
86
|
+
return answer.trim()
|
|
87
|
+
} catch {
|
|
88
|
+
cancel()
|
|
89
|
+
} finally {
|
|
90
|
+
rl.close()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const validators = {
|
|
95
|
+
identifier: (value) => /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(value)
|
|
96
|
+
? null
|
|
97
|
+
: 'Only letters, numbers and _ are allowed, and it cannot start with a number.',
|
|
98
|
+
host: (value) => /^[^\s/:@]+$/.test(value) ? null : 'Enter a valid host, for example localhost.',
|
|
99
|
+
port: (value) => {
|
|
100
|
+
const port = Number(value)
|
|
101
|
+
return Number.isInteger(port) && port > 0 && port <= 65535 ? null : 'Enter a number between 1 and 65535.'
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function askWithDefault(label, defaultValue, validate) {
|
|
106
|
+
const hint = defaultValue ? styleText('gray', ` (${defaultValue})`) : ''
|
|
107
|
+
|
|
108
|
+
while (true) {
|
|
109
|
+
const value = (await ask(`${styleText('bold', label)}${hint}: `)) || defaultValue
|
|
110
|
+
const error = validate?.(value)
|
|
111
|
+
if (!error) return value
|
|
112
|
+
console.log(` ${styleText('red', '✖')} ${styleText('red', error)}`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function printRetry(message) {
|
|
117
|
+
console.log(`\n${styleText('red', '✖')} ${styleText('red', message)} ${styleText('gray', 'Try again.')}\n`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function askConnection(previous) {
|
|
121
|
+
return {
|
|
122
|
+
user: await askWithDefault('User', previous.user, validators.identifier),
|
|
123
|
+
password: await askWithDefault('Password', ''),
|
|
124
|
+
host: await askWithDefault('Host', previous.host, validators.host),
|
|
125
|
+
port: Number(await askWithDefault('Port', String(previous.port), validators.port)),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function createDatabase(targetDir, db) {
|
|
130
|
+
const pg = createRequire(join(targetDir, 'package.json'))('pg')
|
|
131
|
+
const client = new pg.Client({ ...db, database: 'postgres' })
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
await client.connect()
|
|
135
|
+
const { rowCount } = await client.query('SELECT 1 FROM pg_database WHERE datname = $1', [db.database])
|
|
136
|
+
if (rowCount > 0) return 'exists'
|
|
137
|
+
await client.query(`CREATE DATABASE "${db.database}"`)
|
|
138
|
+
return 'created'
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error.code === '42P04') return 'exists'
|
|
141
|
+
if (error.code === '42501') return 'denied'
|
|
142
|
+
if (error.code === '28P01' || error.code === '28000') return 'auth'
|
|
143
|
+
if (['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN'].includes(error.code)) return 'unreachable'
|
|
144
|
+
fail(`Could not create the database: ${error.message}`)
|
|
145
|
+
} finally {
|
|
146
|
+
await client.end().catch(() => {})
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function createDatabaseAsAdmin(targetDir, db) {
|
|
151
|
+
console.log(`\n${styleText('gray', 'Enter a PostgreSQL admin account (the one created when PostgreSQL was installed).')}`)
|
|
152
|
+
const pg = createRequire(join(targetDir, 'package.json'))('pg')
|
|
153
|
+
let adminUser = 'postgres'
|
|
154
|
+
|
|
155
|
+
while (true) {
|
|
156
|
+
adminUser = await askWithDefault('Admin user', adminUser, validators.identifier)
|
|
157
|
+
const password = await askWithDefault('Admin password', '')
|
|
158
|
+
const client = new pg.Client({ host: db.host, port: db.port, database: 'postgres', user: adminUser, password })
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
await client.connect()
|
|
162
|
+
await client.query(`CREATE DATABASE "${db.database}" OWNER "${db.user}"`)
|
|
163
|
+
return
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error.code === '28P01' || error.code === '28000') printRetry(`Wrong password for PostgreSQL admin "${adminUser}".`)
|
|
166
|
+
else if (error.code === '42501') printRetry(`"${adminUser}" is not allowed to create databases either.`)
|
|
167
|
+
else fail(`Could not create the database: ${error.message}`)
|
|
168
|
+
} finally {
|
|
169
|
+
await client.end().catch(() => {})
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function createDatabaseWithSudo(db) {
|
|
175
|
+
console.log(`\n${styleText('gray', '$')} ${styleText('cyan', `sudo -u postgres psql -c 'CREATE DATABASE "${db.database}" OWNER "${db.user}";'`)}`)
|
|
176
|
+
return new Promise((resolvePromise) => {
|
|
177
|
+
const child = spawn('sudo', ['-u', 'postgres', 'psql', '-c', `CREATE DATABASE "${db.database}" OWNER "${db.user}";`], { stdio: 'inherit' })
|
|
178
|
+
child.on('close', (code) => resolvePromise(code))
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function setupDatabase(targetDir, projectName) {
|
|
183
|
+
console.log(`\n${styleText('magenta', '🐘')} PostgreSQL setup\n`)
|
|
184
|
+
|
|
185
|
+
const defaultName = projectName.toLowerCase().replace(/[^a-z0-9_]/g, '_')
|
|
186
|
+
const database = await askWithDefault('Database name', defaultName, validators.identifier)
|
|
187
|
+
|
|
188
|
+
let db = { database, user: 'postgres', host: 'localhost', port: 5432 }
|
|
189
|
+
let status
|
|
190
|
+
|
|
191
|
+
while (true) {
|
|
192
|
+
db = { database, ...(await askConnection(db)) }
|
|
193
|
+
status = await createDatabase(targetDir, db)
|
|
194
|
+
|
|
195
|
+
if (status === 'auth') printRetry(`Wrong user or password for "${db.user}".`)
|
|
196
|
+
else if (status === 'unreachable') printRetry(`Cannot reach PostgreSQL on ${db.host}:${db.port}. Is it running?`)
|
|
197
|
+
else break
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (status === 'denied') {
|
|
201
|
+
console.log(`\n${styleText('yellow', '⚠')} User "${db.user}" is not allowed to create databases.`)
|
|
202
|
+
|
|
203
|
+
const useSudo = process.platform === 'linux'
|
|
204
|
+
&& ['', 'y', 'yes'].includes((await ask(`${styleText('yellow', '?')} Create it with the "postgres" system user (sudo)? ${styleText('gray', '(Y/n)')} `)).toLowerCase())
|
|
205
|
+
|
|
206
|
+
const createdWithSudo = useSudo && (await createDatabaseWithSudo(db)) === 0
|
|
207
|
+
|
|
208
|
+
if (!createdWithSudo) {
|
|
209
|
+
await createDatabaseAsAdmin(targetDir, db)
|
|
210
|
+
}
|
|
211
|
+
status = 'created'
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const url = `postgres://${encodeURIComponent(db.user)}:${encodeURIComponent(db.password)}@${db.host}:${db.port}/${db.database}`
|
|
215
|
+
const envPath = join(targetDir, '.env')
|
|
216
|
+
writeFileSync(envPath, readFileSync(envPath, 'utf8').replace(/^DATABASE_URL=.*$/m, `DATABASE_URL=${url}`))
|
|
217
|
+
|
|
218
|
+
const maskedUrl = url.replace(`:${encodeURIComponent(db.password)}@`, ':****@')
|
|
219
|
+
console.log(`
|
|
220
|
+
${styleText('green', '✔')} Database ${styleText('cyan', db.database)} ${status === 'exists' ? 'already exists, using it' : 'created'}
|
|
221
|
+
|
|
222
|
+
${styleText('gray', 'Host ')} ${db.host}:${db.port}
|
|
223
|
+
${styleText('gray', 'Database ')} ${db.database}
|
|
224
|
+
${styleText('gray', 'User ')} ${db.user}
|
|
225
|
+
${styleText('gray', 'URL ')} ${maskedUrl}
|
|
226
|
+
${styleText('gray', 'Saved in ')} .env`)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function runSetup(targetDir, projectName) {
|
|
230
|
+
await setupDatabase(targetDir, projectName)
|
|
231
|
+
|
|
232
|
+
for (const args of [['run', 'migrate:up'], ['run', 'seed']]) {
|
|
233
|
+
const code = await runCommand(targetDir, args)
|
|
234
|
+
if (code !== 0) {
|
|
235
|
+
fail(`"npm ${args.join(' ')}" failed. Fix the error above, then create the project again.`)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
createdDir = null
|
|
240
|
+
await runCommand(targetDir, ['run', 'dev'])
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function main() {
|
|
244
|
+
const projectName = process.argv[2]
|
|
245
|
+
|
|
246
|
+
if (!projectName) {
|
|
247
|
+
fail('Usage: npm create simo-api@latest <project-name>')
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const targetDir = resolve(process.cwd(), projectName)
|
|
251
|
+
|
|
252
|
+
if (existsSync(targetDir) && readdirSync(targetDir).length > 0) {
|
|
253
|
+
fail(`Directory "${projectName}" already exists and is not empty.`)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
console.log(`\n${styleText('yellow', '⚡')} We will scaffold your app in a few seconds..\n`)
|
|
257
|
+
|
|
258
|
+
mkdirSync(targetDir, { recursive: true })
|
|
259
|
+
createdDir = targetDir
|
|
260
|
+
cpSync(TEMPLATE_DIR, targetDir, { recursive: true })
|
|
261
|
+
|
|
262
|
+
for (const [from, to] of Object.entries(RENAME)) {
|
|
263
|
+
const src = join(targetDir, from)
|
|
264
|
+
if (existsSync(src)) renameSync(src, join(targetDir, to))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const pkgPath = join(targetDir, 'package.json')
|
|
268
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
|
269
|
+
pkg.name = projectName
|
|
270
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
|
271
|
+
|
|
272
|
+
const env = readFileSync(join(targetDir, '.env.example'), 'utf8')
|
|
273
|
+
.replace(/^ACCESS_TOKEN_SECRET=$/m, `ACCESS_TOKEN_SECRET=${randomBytes(64).toString('hex')}`)
|
|
274
|
+
.replace(/^REFRESH_TOKEN_SECRET=$/m, `REFRESH_TOKEN_SECRET=${randomBytes(64).toString('hex')}`)
|
|
275
|
+
.replace(/^ADMIN_PASSWORD=$/m, `ADMIN_PASSWORD=${randomBytes(12).toString('base64url')}`)
|
|
276
|
+
writeFileSync(join(targetDir, '.env'), env)
|
|
277
|
+
|
|
278
|
+
for (const file of listFiles(targetDir)) {
|
|
279
|
+
const path = relative(process.cwd(), file)
|
|
280
|
+
console.log(`${styleText('green', 'CREATE')} ${path} ${styleText('gray', `(${statSync(file).size} bytes)`)}`)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
console.log()
|
|
284
|
+
const install = await installDependencies(targetDir)
|
|
285
|
+
|
|
286
|
+
if (install.code !== 0) {
|
|
287
|
+
console.error(styleText('gray', install.output))
|
|
288
|
+
fail('Dependency installation failed. Check your internet connection and npm, then try again.')
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
console.log(`
|
|
292
|
+
${styleText('magenta', '🚀')} Successfully created project ${styleText('cyan', projectName)}
|
|
293
|
+
${styleText('green', ' Welcome to Simo Express App. Happy coding!')}
|
|
294
|
+
|
|
295
|
+
👉 Get started with the following commands:
|
|
296
|
+
|
|
297
|
+
${styleText('gray', '$')} ${styleText('cyan', `cd ${projectName}`)}
|
|
298
|
+
${styleText('gray', '$ # set DATABASE_URL in .env')}
|
|
299
|
+
${styleText('gray', '$')} ${styleText('cyan', 'npm run migrate:up')}
|
|
300
|
+
${styleText('gray', '$')} ${styleText('cyan', 'npm run seed')}
|
|
301
|
+
${styleText('gray', '$')} ${styleText('cyan', 'npm run dev')}
|
|
302
|
+
`)
|
|
303
|
+
|
|
304
|
+
if (!process.stdin.isTTY) {
|
|
305
|
+
createdDir = null
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const answer = await ask(`${styleText('yellow', '?')} Do you want me to run these commands for you? ${styleText('gray', '(Y/n)')} `)
|
|
310
|
+
|
|
311
|
+
if (answer === '' || answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
|
|
312
|
+
await runSetup(targetDir, projectName)
|
|
313
|
+
} else {
|
|
314
|
+
createdDir = null
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
main()
|
package/package.json
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-simo-api",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Scaffold an Express 5 + PostgreSQL API with JWT auth (access + refresh rotation), role permissions, Zod validation, service layer, migrations, seed and tests.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-simo-api": "index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"template"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"express",
|
|
15
|
+
"postgresql",
|
|
16
|
+
"jwt",
|
|
17
|
+
"api",
|
|
18
|
+
"boilerplate",
|
|
19
|
+
"scaffold",
|
|
20
|
+
"create"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Simo API
|
|
2
|
+
|
|
3
|
+
API Express 5 + PostgreSQL.
|
|
4
|
+
|
|
5
|
+
## Démarrage
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
cp .env.example .env # si .env n'existe pas, puis remplir DATABASE_URL
|
|
10
|
+
npm run migrate:up
|
|
11
|
+
npm run seed # crée l'admin du .env
|
|
12
|
+
npm run dev
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Générer un secret JWT :
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Structure
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
app.js app Express (middlewares, routes)
|
|
25
|
+
index.js lance le serveur
|
|
26
|
+
config/ env (validé avec Zod), db, permissions
|
|
27
|
+
routes/ auth, user, product
|
|
28
|
+
controllers/ req/res seulement
|
|
29
|
+
services/ logique métier
|
|
30
|
+
models/ requêtes SQL
|
|
31
|
+
middlewares/ auth, authorize, validate, rateLimit, errors
|
|
32
|
+
validations/ schémas Zod
|
|
33
|
+
utils/ AppError, verifyToken
|
|
34
|
+
migrations/
|
|
35
|
+
seeds/
|
|
36
|
+
tests/
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Routes
|
|
40
|
+
|
|
41
|
+
| Route | Accès |
|
|
42
|
+
|---|---|
|
|
43
|
+
| `POST /api/auth/register` · `login` | public |
|
|
44
|
+
| `POST /api/auth/refresh` · `logout` | cookie `refreshToken` |
|
|
45
|
+
| `GET /api/user/me` | connecté |
|
|
46
|
+
| `GET /api/user/getAll` | admin |
|
|
47
|
+
| `POST /api/user/create` | connecté (seul l'admin choisit le rôle) |
|
|
48
|
+
| `GET /api/product/getAll` · `/:id` | public |
|
|
49
|
+
| `POST /api/product/create` | admin |
|
|
50
|
+
| `PATCH /api/product/update/:id` | admin |
|
|
51
|
+
| `DELETE /api/product/delete/:id` | admin |
|
|
52
|
+
|
|
53
|
+
## Fonctionnement
|
|
54
|
+
|
|
55
|
+
- **Auth** : access token JWT (`ACCESS_TOKEN_EXPIRES_IN`, 15m par défaut) dans `Authorization: Bearer`, refresh token (`REFRESH_TOKEN_EXPIRES_DAYS`, 7 j par défaut) en cookie `httpOnly`, stocké hashé dans `refresh_tokens`. Rotation à chaque refresh ; un token réutilisé révoque toutes les sessions de l'utilisateur.
|
|
56
|
+
- **Permissions** : `authorize('PERMISSION')` sur la route, rôles autorisés dans `config/permissions.js`.
|
|
57
|
+
- **Validation** : `validate(schema)` pour le body, `validate(schema, 'params')` pour les paramètres.
|
|
58
|
+
- **Erreurs** : `throw new AppError(message, status)` dans les controllers (pas de try/catch). `errorHandler` renvoie du JSON, convertit les erreurs PostgreSQL (doublon → 409) et renvoie 404 pour les routes inconnues.
|
|
59
|
+
- **Env** : le serveur refuse de démarrer si une variable manque ou est invalide.
|
|
60
|
+
- **Logs** : morgan (`dev` en développement, `combined` en production).
|
|
61
|
+
- **Rate limit** : 100 requêtes / 15 min par IP sur `/api`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
NODE_ENV=development
|
|
2
|
+
PORT=3000
|
|
3
|
+
|
|
4
|
+
DATABASE_URL=postgres://user:password@localhost:5432/dbname
|
|
5
|
+
|
|
6
|
+
CORS_ORIGIN=http://localhost:5173
|
|
7
|
+
|
|
8
|
+
ACCESS_TOKEN_SECRET=
|
|
9
|
+
REFRESH_TOKEN_SECRET=
|
|
10
|
+
ACCESS_TOKEN_EXPIRES_IN=15m
|
|
11
|
+
REFRESH_TOKEN_EXPIRES_DAYS=7
|
|
12
|
+
|
|
13
|
+
ADMIN_NAME=Admin
|
|
14
|
+
ADMIN_EMAIL=admin@example.com
|
|
15
|
+
ADMIN_PASSWORD=
|
package/template/app.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import cors from 'cors';
|
|
3
|
+
import cookieParser from 'cookie-parser';
|
|
4
|
+
import morgan from 'morgan';
|
|
5
|
+
import env from './config/env.js';
|
|
6
|
+
import routes from './routes/index.js';
|
|
7
|
+
import { notFound, errorHandler } from './middlewares/errorMiddleware.js';
|
|
8
|
+
import { apiLimiter } from './middlewares/rateLimitMiddleware.js';
|
|
9
|
+
|
|
10
|
+
const app = express();
|
|
11
|
+
|
|
12
|
+
if (env.NODE_ENV !== 'test') {
|
|
13
|
+
app.use(morgan(env.NODE_ENV === 'production' ? 'combined' : 'dev'));
|
|
14
|
+
}
|
|
15
|
+
app.use(express.json());
|
|
16
|
+
app.use(cookieParser());
|
|
17
|
+
|
|
18
|
+
app.use(cors({ origin: env.CORS_ORIGIN, credentials: true }));
|
|
19
|
+
|
|
20
|
+
app.get('/', (req, res) => {
|
|
21
|
+
res.send('API is running...');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
app.use('/api', apiLimiter, routes);
|
|
25
|
+
|
|
26
|
+
app.use(notFound);
|
|
27
|
+
app.use(errorHandler);
|
|
28
|
+
|
|
29
|
+
export default app;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
const envSchema = z.object({
|
|
5
|
+
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
|
6
|
+
PORT: z.coerce.number().int().positive().default(3000),
|
|
7
|
+
DATABASE_URL: z.url(),
|
|
8
|
+
ACCESS_TOKEN_SECRET: z.string().min(32),
|
|
9
|
+
REFRESH_TOKEN_SECRET: z.string().min(32),
|
|
10
|
+
ACCESS_TOKEN_EXPIRES_IN: z.string().regex(/^\d+(ms|s|m|h|d)$/, 'Use a duration like 15m, 1h or 1d').default('15m'),
|
|
11
|
+
REFRESH_TOKEN_EXPIRES_DAYS: z.coerce.number().int().positive().default(7),
|
|
12
|
+
CORS_ORIGIN: z.string().min(1).transform((value) => value.split(',').map((origin) => origin.trim())),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const result = envSchema.safeParse(process.env);
|
|
16
|
+
|
|
17
|
+
if (!result.success) {
|
|
18
|
+
console.error('Invalid environment variables:');
|
|
19
|
+
for (const issue of result.error.issues) {
|
|
20
|
+
console.error(` - ${issue.path.join('.')}: ${issue.message}`);
|
|
21
|
+
}
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const env = result.data;
|
|
26
|
+
|
|
27
|
+
export default env;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const ROLES = {
|
|
2
|
+
ADMIN: 'admin',
|
|
3
|
+
USER: 'user'
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
export const PERMISSIONS = {
|
|
8
|
+
USER_CREATE: [ROLES.ADMIN, ROLES.USER],
|
|
9
|
+
USER_DELETE: [ROLES.ADMIN],
|
|
10
|
+
USER_GETALL: [ROLES.ADMIN],
|
|
11
|
+
PRODUCT_CREATE: [ROLES.ADMIN],
|
|
12
|
+
PRODUCT_UPDATE: [ROLES.ADMIN],
|
|
13
|
+
PRODUCT_DELETE: [ROLES.ADMIN],
|
|
14
|
+
PRODUCT_GETALL: [ROLES.ADMIN, ROLES.USER]
|
|
15
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import authService, { REFRESH_TOKEN_MAX_AGE } from '../services/authService.js';
|
|
2
|
+
import env from '../config/env.js';
|
|
3
|
+
|
|
4
|
+
const refreshCookieOptions = {
|
|
5
|
+
httpOnly: true,
|
|
6
|
+
secure: env.NODE_ENV === "production",
|
|
7
|
+
sameSite: "strict",
|
|
8
|
+
path: "/api/auth",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const sendRefreshCookie = (res, token) => {
|
|
12
|
+
res.cookie("refreshToken", token, {
|
|
13
|
+
...refreshCookieOptions,
|
|
14
|
+
maxAge: REFRESH_TOKEN_MAX_AGE
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const login = async (req, res) => {
|
|
19
|
+
const { accessToken, refreshToken, user } = await authService.login(req.body.email, req.body.password);
|
|
20
|
+
sendRefreshCookie(res, refreshToken);
|
|
21
|
+
res.status(200).json({ message: 'Login successful', accessToken, user });
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const register = async (req, res) => {
|
|
25
|
+
const userId = await authService.register(req.body);
|
|
26
|
+
res.status(201).json({ message: 'User registered successfully', userId });
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const refresh = async (req, res) => {
|
|
30
|
+
const { accessToken, refreshToken } = await authService.refresh(req.cookies.refreshToken);
|
|
31
|
+
sendRefreshCookie(res, refreshToken);
|
|
32
|
+
res.status(200).json({ accessToken });
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const clearRefreshCookieOnError = (err, req, res, next) => {
|
|
36
|
+
res.clearCookie("refreshToken", refreshCookieOptions);
|
|
37
|
+
next(err);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const logout = async (req, res) => {
|
|
41
|
+
await authService.logout(req.cookies.refreshToken);
|
|
42
|
+
res.clearCookie("refreshToken", refreshCookieOptions);
|
|
43
|
+
res.status(200).json({ message: 'Logged out' });
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
export default {
|
|
48
|
+
login,
|
|
49
|
+
register,
|
|
50
|
+
refresh,
|
|
51
|
+
clearRefreshCookieOnError,
|
|
52
|
+
logout
|
|
53
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import productService from '../services/productService.js';
|
|
2
|
+
|
|
3
|
+
const getAllProducts = async (req, res) => {
|
|
4
|
+
const products = await productService.getAll();
|
|
5
|
+
res.status(200).json({ products });
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const getProductById = async (req, res) => {
|
|
9
|
+
const product = await productService.getById(req.params.id);
|
|
10
|
+
res.status(200).json({ product });
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const createProduct = async (req, res) => {
|
|
14
|
+
const product = await productService.create(req.body);
|
|
15
|
+
res.status(201).json({ message: 'Product created successfully', product });
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const updateProduct = async (req, res) => {
|
|
19
|
+
const product = await productService.update(req.params.id, req.body);
|
|
20
|
+
res.status(200).json({ message: 'Product updated successfully', product });
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const deleteProduct = async (req, res) => {
|
|
24
|
+
await productService.remove(req.params.id);
|
|
25
|
+
res.status(200).json({ message: 'Product deleted successfully' });
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export default {
|
|
29
|
+
getAllProducts,
|
|
30
|
+
getProductById,
|
|
31
|
+
createProduct,
|
|
32
|
+
updateProduct,
|
|
33
|
+
deleteProduct
|
|
34
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import userService from '../services/userService.js';
|
|
2
|
+
|
|
3
|
+
const getAllUsers = async (req, res) => {
|
|
4
|
+
const users = await userService.getAll();
|
|
5
|
+
res.status(200).json({ users });
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const getMe = async (req, res) => {
|
|
9
|
+
const user = await userService.getById(req.user.id);
|
|
10
|
+
res.status(200).json({ user });
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const createUser = async (req, res) => {
|
|
14
|
+
const userId = await userService.create(req.body, req.user);
|
|
15
|
+
res.status(201).json({ message: 'User created successfully', userId });
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export default {
|
|
19
|
+
getAllUsers,
|
|
20
|
+
getMe,
|
|
21
|
+
createUser
|
|
22
|
+
};
|