servcraft 0.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/.dockerignore +45 -0
- package/.env.example +46 -0
- package/.husky/commit-msg +1 -0
- package/.husky/pre-commit +1 -0
- package/.prettierignore +4 -0
- package/.prettierrc +11 -0
- package/Dockerfile +76 -0
- package/Dockerfile.dev +31 -0
- package/README.md +232 -0
- package/commitlint.config.js +24 -0
- package/dist/cli/index.cjs +3968 -0
- package/dist/cli/index.cjs.map +1 -0
- package/dist/cli/index.d.cts +1 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +3945 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.cjs +2458 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +828 -0
- package/dist/index.d.ts +828 -0
- package/dist/index.js +2332 -0
- package/dist/index.js.map +1 -0
- package/docker-compose.prod.yml +118 -0
- package/docker-compose.yml +147 -0
- package/eslint.config.js +27 -0
- package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
- package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
- package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
- package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
- package/npm-cache/_cacache/index-v5/58/94/c2cba79e0f16b4c10e95a87e32255741149e8222cc314a476aab67c39cc0 +5 -0
- package/npm-cache/_update-notifier-last-checked +0 -0
- package/package.json +112 -0
- package/prisma/schema.prisma +157 -0
- package/src/cli/commands/add-module.ts +422 -0
- package/src/cli/commands/db.ts +137 -0
- package/src/cli/commands/docs.ts +16 -0
- package/src/cli/commands/generate.ts +459 -0
- package/src/cli/commands/init.ts +640 -0
- package/src/cli/index.ts +32 -0
- package/src/cli/templates/controller.ts +67 -0
- package/src/cli/templates/dynamic-prisma.ts +89 -0
- package/src/cli/templates/dynamic-schemas.ts +232 -0
- package/src/cli/templates/dynamic-types.ts +60 -0
- package/src/cli/templates/module-index.ts +33 -0
- package/src/cli/templates/prisma-model.ts +17 -0
- package/src/cli/templates/repository.ts +104 -0
- package/src/cli/templates/routes.ts +70 -0
- package/src/cli/templates/schemas.ts +26 -0
- package/src/cli/templates/service.ts +58 -0
- package/src/cli/templates/types.ts +27 -0
- package/src/cli/utils/docs-generator.ts +47 -0
- package/src/cli/utils/field-parser.ts +315 -0
- package/src/cli/utils/helpers.ts +89 -0
- package/src/config/env.ts +80 -0
- package/src/config/index.ts +97 -0
- package/src/core/index.ts +5 -0
- package/src/core/logger.ts +43 -0
- package/src/core/server.ts +132 -0
- package/src/database/index.ts +7 -0
- package/src/database/prisma.ts +54 -0
- package/src/database/seed.ts +59 -0
- package/src/index.ts +63 -0
- package/src/middleware/error-handler.ts +73 -0
- package/src/middleware/index.ts +3 -0
- package/src/middleware/security.ts +116 -0
- package/src/modules/audit/audit.service.ts +192 -0
- package/src/modules/audit/index.ts +2 -0
- package/src/modules/audit/types.ts +37 -0
- package/src/modules/auth/auth.controller.ts +182 -0
- package/src/modules/auth/auth.middleware.ts +87 -0
- package/src/modules/auth/auth.routes.ts +123 -0
- package/src/modules/auth/auth.service.ts +142 -0
- package/src/modules/auth/index.ts +49 -0
- package/src/modules/auth/schemas.ts +52 -0
- package/src/modules/auth/types.ts +69 -0
- package/src/modules/email/email.service.ts +212 -0
- package/src/modules/email/index.ts +10 -0
- package/src/modules/email/templates.ts +213 -0
- package/src/modules/email/types.ts +57 -0
- package/src/modules/swagger/index.ts +3 -0
- package/src/modules/swagger/schema-builder.ts +263 -0
- package/src/modules/swagger/swagger.service.ts +169 -0
- package/src/modules/swagger/types.ts +68 -0
- package/src/modules/user/index.ts +30 -0
- package/src/modules/user/schemas.ts +49 -0
- package/src/modules/user/types.ts +78 -0
- package/src/modules/user/user.controller.ts +139 -0
- package/src/modules/user/user.repository.ts +156 -0
- package/src/modules/user/user.routes.ts +199 -0
- package/src/modules/user/user.service.ts +145 -0
- package/src/modules/validation/index.ts +18 -0
- package/src/modules/validation/validator.ts +104 -0
- package/src/types/common.ts +61 -0
- package/src/types/index.ts +10 -0
- package/src/utils/errors.ts +66 -0
- package/src/utils/index.ts +33 -0
- package/src/utils/pagination.ts +38 -0
- package/src/utils/response.ts +63 -0
- package/tests/integration/auth.test.ts +59 -0
- package/tests/setup.ts +17 -0
- package/tests/unit/modules/validation.test.ts +88 -0
- package/tests/unit/utils/errors.test.ts +113 -0
- package/tests/unit/utils/pagination.test.ts +82 -0
- package/tsconfig.json +33 -0
- package/tsup.config.ts +14 -0
- package/vitest.config.ts +34 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import Handlebars from 'handlebars';
|
|
2
|
+
|
|
3
|
+
// Base layout template
|
|
4
|
+
const baseLayout = `
|
|
5
|
+
<!DOCTYPE html>
|
|
6
|
+
<html lang="en">
|
|
7
|
+
<head>
|
|
8
|
+
<meta charset="UTF-8">
|
|
9
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
10
|
+
<title>{{subject}}</title>
|
|
11
|
+
<style>
|
|
12
|
+
body {
|
|
13
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
14
|
+
line-height: 1.6;
|
|
15
|
+
color: #333;
|
|
16
|
+
max-width: 600px;
|
|
17
|
+
margin: 0 auto;
|
|
18
|
+
padding: 20px;
|
|
19
|
+
background-color: #f5f5f5;
|
|
20
|
+
}
|
|
21
|
+
.container {
|
|
22
|
+
background-color: #ffffff;
|
|
23
|
+
border-radius: 8px;
|
|
24
|
+
padding: 40px;
|
|
25
|
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
26
|
+
}
|
|
27
|
+
.header {
|
|
28
|
+
text-align: center;
|
|
29
|
+
margin-bottom: 30px;
|
|
30
|
+
}
|
|
31
|
+
.logo {
|
|
32
|
+
font-size: 24px;
|
|
33
|
+
font-weight: bold;
|
|
34
|
+
color: #2563eb;
|
|
35
|
+
}
|
|
36
|
+
.content {
|
|
37
|
+
margin-bottom: 30px;
|
|
38
|
+
}
|
|
39
|
+
.button {
|
|
40
|
+
display: inline-block;
|
|
41
|
+
padding: 12px 24px;
|
|
42
|
+
background-color: #2563eb;
|
|
43
|
+
color: #ffffff !important;
|
|
44
|
+
text-decoration: none;
|
|
45
|
+
border-radius: 6px;
|
|
46
|
+
font-weight: 500;
|
|
47
|
+
}
|
|
48
|
+
.button:hover {
|
|
49
|
+
background-color: #1d4ed8;
|
|
50
|
+
}
|
|
51
|
+
.footer {
|
|
52
|
+
text-align: center;
|
|
53
|
+
font-size: 12px;
|
|
54
|
+
color: #666;
|
|
55
|
+
margin-top: 30px;
|
|
56
|
+
padding-top: 20px;
|
|
57
|
+
border-top: 1px solid #eee;
|
|
58
|
+
}
|
|
59
|
+
.warning {
|
|
60
|
+
background-color: #fef3c7;
|
|
61
|
+
border: 1px solid #f59e0b;
|
|
62
|
+
border-radius: 6px;
|
|
63
|
+
padding: 12px;
|
|
64
|
+
margin: 20px 0;
|
|
65
|
+
}
|
|
66
|
+
</style>
|
|
67
|
+
</head>
|
|
68
|
+
<body>
|
|
69
|
+
<div class="container">
|
|
70
|
+
<div class="header">
|
|
71
|
+
<div class="logo">{{appName}}</div>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="content">
|
|
74
|
+
{{{body}}}
|
|
75
|
+
</div>
|
|
76
|
+
<div class="footer">
|
|
77
|
+
<p>© {{year}} {{appName}}. All rights reserved.</p>
|
|
78
|
+
<p>This email was sent to {{userEmail}}</p>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
</body>
|
|
82
|
+
</html>
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
// Individual templates
|
|
86
|
+
const templates: Record<string, string> = {
|
|
87
|
+
welcome: `
|
|
88
|
+
<h2>Welcome to {{appName}}!</h2>
|
|
89
|
+
<p>Hi {{userName}},</p>
|
|
90
|
+
<p>Thank you for joining {{appName}}. We're excited to have you on board!</p>
|
|
91
|
+
<p>To get started, please verify your email address by clicking the button below:</p>
|
|
92
|
+
<p style="text-align: center; margin: 30px 0;">
|
|
93
|
+
<a href="{{actionUrl}}" class="button">Verify Email</a>
|
|
94
|
+
</p>
|
|
95
|
+
<p>If you didn't create an account with us, you can safely ignore this email.</p>
|
|
96
|
+
`,
|
|
97
|
+
|
|
98
|
+
'verify-email': `
|
|
99
|
+
<h2>Verify Your Email</h2>
|
|
100
|
+
<p>Hi {{userName}},</p>
|
|
101
|
+
<p>Please verify your email address by clicking the button below:</p>
|
|
102
|
+
<p style="text-align: center; margin: 30px 0;">
|
|
103
|
+
<a href="{{actionUrl}}" class="button">Verify Email</a>
|
|
104
|
+
</p>
|
|
105
|
+
<p>This link will expire in {{expiresIn}}.</p>
|
|
106
|
+
<p>If you didn't request this verification, you can safely ignore this email.</p>
|
|
107
|
+
`,
|
|
108
|
+
|
|
109
|
+
'password-reset': `
|
|
110
|
+
<h2>Reset Your Password</h2>
|
|
111
|
+
<p>Hi {{userName}},</p>
|
|
112
|
+
<p>We received a request to reset your password. Click the button below to create a new password:</p>
|
|
113
|
+
<p style="text-align: center; margin: 30px 0;">
|
|
114
|
+
<a href="{{actionUrl}}" class="button">Reset Password</a>
|
|
115
|
+
</p>
|
|
116
|
+
<p>This link will expire in {{expiresIn}}.</p>
|
|
117
|
+
<div class="warning">
|
|
118
|
+
<strong>Security Notice:</strong> If you didn't request this password reset, please ignore this email and your password will remain unchanged.
|
|
119
|
+
</div>
|
|
120
|
+
`,
|
|
121
|
+
|
|
122
|
+
'password-changed': `
|
|
123
|
+
<h2>Password Changed</h2>
|
|
124
|
+
<p>Hi {{userName}},</p>
|
|
125
|
+
<p>Your password has been successfully changed.</p>
|
|
126
|
+
<p>If you didn't make this change, please contact our support team immediately and secure your account.</p>
|
|
127
|
+
<div class="warning">
|
|
128
|
+
<strong>Details:</strong><br>
|
|
129
|
+
Time: {{timestamp}}<br>
|
|
130
|
+
IP Address: {{ipAddress}}<br>
|
|
131
|
+
Device: {{userAgent}}
|
|
132
|
+
</div>
|
|
133
|
+
`,
|
|
134
|
+
|
|
135
|
+
'login-alert': `
|
|
136
|
+
<h2>New Login Detected</h2>
|
|
137
|
+
<p>Hi {{userName}},</p>
|
|
138
|
+
<p>We detected a new login to your account.</p>
|
|
139
|
+
<div class="warning">
|
|
140
|
+
<strong>Login Details:</strong><br>
|
|
141
|
+
Time: {{timestamp}}<br>
|
|
142
|
+
IP Address: {{ipAddress}}<br>
|
|
143
|
+
Device: {{userAgent}}<br>
|
|
144
|
+
Location: {{location}}
|
|
145
|
+
</div>
|
|
146
|
+
<p>If this was you, you can safely ignore this email.</p>
|
|
147
|
+
<p>If you didn't log in, please change your password immediately and contact support.</p>
|
|
148
|
+
`,
|
|
149
|
+
|
|
150
|
+
'account-suspended': `
|
|
151
|
+
<h2>Account Suspended</h2>
|
|
152
|
+
<p>Hi {{userName}},</p>
|
|
153
|
+
<p>Your account has been suspended due to: {{reason}}</p>
|
|
154
|
+
<p>If you believe this is a mistake, please contact our support team.</p>
|
|
155
|
+
`,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// Compile templates
|
|
159
|
+
const compiledLayout = Handlebars.compile(baseLayout);
|
|
160
|
+
const compiledTemplates: Record<string, HandlebarsTemplateDelegate> = {};
|
|
161
|
+
|
|
162
|
+
for (const [name, template] of Object.entries(templates)) {
|
|
163
|
+
compiledTemplates[name] = Handlebars.compile(template);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function renderTemplate(
|
|
167
|
+
templateName: string,
|
|
168
|
+
data: Record<string, unknown>
|
|
169
|
+
): string {
|
|
170
|
+
const template = compiledTemplates[templateName];
|
|
171
|
+
|
|
172
|
+
if (!template) {
|
|
173
|
+
throw new Error(`Template "${templateName}" not found`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const body = template(data);
|
|
177
|
+
|
|
178
|
+
return compiledLayout({
|
|
179
|
+
...data,
|
|
180
|
+
body,
|
|
181
|
+
year: new Date().getFullYear(),
|
|
182
|
+
appName: data.appName || 'Servcraft',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function renderCustomTemplate(
|
|
187
|
+
htmlTemplate: string,
|
|
188
|
+
data: Record<string, unknown>
|
|
189
|
+
): string {
|
|
190
|
+
const template = Handlebars.compile(htmlTemplate);
|
|
191
|
+
const body = template(data);
|
|
192
|
+
|
|
193
|
+
return compiledLayout({
|
|
194
|
+
...data,
|
|
195
|
+
body,
|
|
196
|
+
year: new Date().getFullYear(),
|
|
197
|
+
appName: data.appName || 'Servcraft',
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Register Handlebars helpers
|
|
202
|
+
Handlebars.registerHelper('formatDate', (date: Date) => {
|
|
203
|
+
return new Date(date).toLocaleDateString('en-US', {
|
|
204
|
+
year: 'numeric',
|
|
205
|
+
month: 'long',
|
|
206
|
+
day: 'numeric',
|
|
207
|
+
hour: '2-digit',
|
|
208
|
+
minute: '2-digit',
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
Handlebars.registerHelper('eq', (a, b) => a === b);
|
|
213
|
+
Handlebars.registerHelper('ne', (a, b) => a !== b);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export interface EmailConfig {
|
|
2
|
+
host: string;
|
|
3
|
+
port: number;
|
|
4
|
+
secure?: boolean;
|
|
5
|
+
auth: {
|
|
6
|
+
user: string;
|
|
7
|
+
pass: string;
|
|
8
|
+
};
|
|
9
|
+
from: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface EmailOptions {
|
|
13
|
+
to: string | string[];
|
|
14
|
+
subject: string;
|
|
15
|
+
html?: string;
|
|
16
|
+
text?: string;
|
|
17
|
+
template?: string;
|
|
18
|
+
data?: Record<string, unknown>;
|
|
19
|
+
attachments?: EmailAttachment[];
|
|
20
|
+
replyTo?: string;
|
|
21
|
+
cc?: string | string[];
|
|
22
|
+
bcc?: string | string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface EmailAttachment {
|
|
26
|
+
filename: string;
|
|
27
|
+
content?: string | Buffer;
|
|
28
|
+
path?: string;
|
|
29
|
+
contentType?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface EmailResult {
|
|
33
|
+
success: boolean;
|
|
34
|
+
messageId?: string;
|
|
35
|
+
error?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type EmailTemplate =
|
|
39
|
+
| 'welcome'
|
|
40
|
+
| 'verify-email'
|
|
41
|
+
| 'password-reset'
|
|
42
|
+
| 'password-changed'
|
|
43
|
+
| 'login-alert'
|
|
44
|
+
| 'account-suspended'
|
|
45
|
+
| 'custom';
|
|
46
|
+
|
|
47
|
+
export interface TemplateData {
|
|
48
|
+
appName?: string;
|
|
49
|
+
userName?: string;
|
|
50
|
+
userEmail?: string;
|
|
51
|
+
actionUrl?: string;
|
|
52
|
+
token?: string;
|
|
53
|
+
expiresIn?: string;
|
|
54
|
+
ipAddress?: string;
|
|
55
|
+
userAgent?: string;
|
|
56
|
+
[key: string]: unknown;
|
|
57
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { registerSwagger, commonResponses, paginationQuery, idParam } from './swagger.service.js';
|
|
2
|
+
export { buildOpenApiSchema, generateRouteSchema } from './schema-builder.js';
|
|
3
|
+
export type { SwaggerConfig, SwaggerTag, SwaggerServer, RouteSchema, EndpointDoc } from './types.js';
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type { FieldDefinition } from '../../cli/utils/field-parser.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build OpenAPI schema from field definitions
|
|
5
|
+
*/
|
|
6
|
+
export function buildOpenApiSchema(
|
|
7
|
+
fields: FieldDefinition[],
|
|
8
|
+
options: { includeId?: boolean; includeTimestamps?: boolean } = {}
|
|
9
|
+
): Record<string, unknown> {
|
|
10
|
+
const { includeId = false, includeTimestamps = false } = options;
|
|
11
|
+
|
|
12
|
+
const properties: Record<string, unknown> = {};
|
|
13
|
+
const required: string[] = [];
|
|
14
|
+
|
|
15
|
+
if (includeId) {
|
|
16
|
+
properties.id = { type: 'string', format: 'uuid', description: 'Unique identifier' };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
for (const field of fields) {
|
|
20
|
+
properties[field.name] = fieldToOpenApi(field);
|
|
21
|
+
if (!field.isOptional) {
|
|
22
|
+
required.push(field.name);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (includeTimestamps) {
|
|
27
|
+
properties.createdAt = { type: 'string', format: 'date-time', description: 'Creation timestamp' };
|
|
28
|
+
properties.updatedAt = { type: 'string', format: 'date-time', description: 'Last update timestamp' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties,
|
|
34
|
+
...(required.length > 0 ? { required } : {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Convert field definition to OpenAPI property schema
|
|
40
|
+
*/
|
|
41
|
+
function fieldToOpenApi(field: FieldDefinition): Record<string, unknown> {
|
|
42
|
+
const typeMap: Record<string, Record<string, unknown>> = {
|
|
43
|
+
string: { type: 'string' },
|
|
44
|
+
number: { type: 'number' },
|
|
45
|
+
int: { type: 'integer' },
|
|
46
|
+
float: { type: 'number', format: 'float' },
|
|
47
|
+
decimal: { type: 'number', format: 'double' },
|
|
48
|
+
boolean: { type: 'boolean' },
|
|
49
|
+
date: { type: 'string', format: 'date' },
|
|
50
|
+
datetime: { type: 'string', format: 'date-time' },
|
|
51
|
+
text: { type: 'string', maxLength: 65535 },
|
|
52
|
+
email: { type: 'string', format: 'email' },
|
|
53
|
+
url: { type: 'string', format: 'uri' },
|
|
54
|
+
uuid: { type: 'string', format: 'uuid' },
|
|
55
|
+
json: { type: 'object', additionalProperties: true },
|
|
56
|
+
enum: { type: 'string' },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
let schema = typeMap[field.type] || { type: 'string' };
|
|
60
|
+
|
|
61
|
+
if (field.isArray) {
|
|
62
|
+
schema = {
|
|
63
|
+
type: 'array',
|
|
64
|
+
items: schema,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return schema;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Generate route schema for Fastify with OpenAPI annotations
|
|
73
|
+
*/
|
|
74
|
+
export function generateRouteSchema(
|
|
75
|
+
modelName: string,
|
|
76
|
+
fields: FieldDefinition[],
|
|
77
|
+
operation: 'list' | 'get' | 'create' | 'update' | 'delete'
|
|
78
|
+
): Record<string, unknown> {
|
|
79
|
+
const tag = modelName + 's';
|
|
80
|
+
|
|
81
|
+
switch (operation) {
|
|
82
|
+
case 'list':
|
|
83
|
+
return {
|
|
84
|
+
schema: {
|
|
85
|
+
summary: `List all ${modelName}s`,
|
|
86
|
+
description: `Retrieve a paginated list of ${modelName}s`,
|
|
87
|
+
tags: [tag],
|
|
88
|
+
querystring: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: {
|
|
91
|
+
page: { type: 'integer', minimum: 1, default: 1 },
|
|
92
|
+
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
|
|
93
|
+
sortBy: { type: 'string' },
|
|
94
|
+
sortOrder: { type: 'string', enum: ['asc', 'desc'] },
|
|
95
|
+
search: { type: 'string' },
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
response: {
|
|
99
|
+
200: {
|
|
100
|
+
description: 'Successful response',
|
|
101
|
+
type: 'object',
|
|
102
|
+
properties: {
|
|
103
|
+
success: { type: 'boolean' },
|
|
104
|
+
data: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
data: {
|
|
108
|
+
type: 'array',
|
|
109
|
+
items: buildOpenApiSchema(fields, { includeId: true, includeTimestamps: true }),
|
|
110
|
+
},
|
|
111
|
+
meta: {
|
|
112
|
+
type: 'object',
|
|
113
|
+
properties: {
|
|
114
|
+
total: { type: 'integer' },
|
|
115
|
+
page: { type: 'integer' },
|
|
116
|
+
limit: { type: 'integer' },
|
|
117
|
+
totalPages: { type: 'integer' },
|
|
118
|
+
hasNextPage: { type: 'boolean' },
|
|
119
|
+
hasPrevPage: { type: 'boolean' },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
case 'get':
|
|
131
|
+
return {
|
|
132
|
+
schema: {
|
|
133
|
+
summary: `Get ${modelName} by ID`,
|
|
134
|
+
description: `Retrieve a single ${modelName} by its ID`,
|
|
135
|
+
tags: [tag],
|
|
136
|
+
params: {
|
|
137
|
+
type: 'object',
|
|
138
|
+
properties: {
|
|
139
|
+
id: { type: 'string', format: 'uuid' },
|
|
140
|
+
},
|
|
141
|
+
required: ['id'],
|
|
142
|
+
},
|
|
143
|
+
response: {
|
|
144
|
+
200: {
|
|
145
|
+
description: 'Successful response',
|
|
146
|
+
type: 'object',
|
|
147
|
+
properties: {
|
|
148
|
+
success: { type: 'boolean' },
|
|
149
|
+
data: buildOpenApiSchema(fields, { includeId: true, includeTimestamps: true }),
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
404: {
|
|
153
|
+
description: 'Not found',
|
|
154
|
+
type: 'object',
|
|
155
|
+
properties: {
|
|
156
|
+
success: { type: 'boolean', example: false },
|
|
157
|
+
message: { type: 'string', example: `${modelName} not found` },
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
case 'create':
|
|
165
|
+
return {
|
|
166
|
+
schema: {
|
|
167
|
+
summary: `Create ${modelName}`,
|
|
168
|
+
description: `Create a new ${modelName}`,
|
|
169
|
+
tags: [tag],
|
|
170
|
+
body: buildOpenApiSchema(fields),
|
|
171
|
+
response: {
|
|
172
|
+
201: {
|
|
173
|
+
description: 'Created successfully',
|
|
174
|
+
type: 'object',
|
|
175
|
+
properties: {
|
|
176
|
+
success: { type: 'boolean' },
|
|
177
|
+
data: buildOpenApiSchema(fields, { includeId: true, includeTimestamps: true }),
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
400: {
|
|
181
|
+
description: 'Validation error',
|
|
182
|
+
type: 'object',
|
|
183
|
+
properties: {
|
|
184
|
+
success: { type: 'boolean', example: false },
|
|
185
|
+
message: { type: 'string' },
|
|
186
|
+
errors: { type: 'object' },
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
case 'update':
|
|
194
|
+
// Make all fields optional for update
|
|
195
|
+
const optionalFields = fields.map((f) => ({ ...f, isOptional: true }));
|
|
196
|
+
return {
|
|
197
|
+
schema: {
|
|
198
|
+
summary: `Update ${modelName}`,
|
|
199
|
+
description: `Update an existing ${modelName}`,
|
|
200
|
+
tags: [tag],
|
|
201
|
+
params: {
|
|
202
|
+
type: 'object',
|
|
203
|
+
properties: {
|
|
204
|
+
id: { type: 'string', format: 'uuid' },
|
|
205
|
+
},
|
|
206
|
+
required: ['id'],
|
|
207
|
+
},
|
|
208
|
+
body: buildOpenApiSchema(optionalFields),
|
|
209
|
+
response: {
|
|
210
|
+
200: {
|
|
211
|
+
description: 'Updated successfully',
|
|
212
|
+
type: 'object',
|
|
213
|
+
properties: {
|
|
214
|
+
success: { type: 'boolean' },
|
|
215
|
+
data: buildOpenApiSchema(fields, { includeId: true, includeTimestamps: true }),
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
404: {
|
|
219
|
+
description: 'Not found',
|
|
220
|
+
type: 'object',
|
|
221
|
+
properties: {
|
|
222
|
+
success: { type: 'boolean', example: false },
|
|
223
|
+
message: { type: 'string' },
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
case 'delete':
|
|
231
|
+
return {
|
|
232
|
+
schema: {
|
|
233
|
+
summary: `Delete ${modelName}`,
|
|
234
|
+
description: `Delete a ${modelName} by ID`,
|
|
235
|
+
tags: [tag],
|
|
236
|
+
params: {
|
|
237
|
+
type: 'object',
|
|
238
|
+
properties: {
|
|
239
|
+
id: { type: 'string', format: 'uuid' },
|
|
240
|
+
},
|
|
241
|
+
required: ['id'],
|
|
242
|
+
},
|
|
243
|
+
response: {
|
|
244
|
+
204: {
|
|
245
|
+
description: 'Deleted successfully',
|
|
246
|
+
type: 'null',
|
|
247
|
+
},
|
|
248
|
+
404: {
|
|
249
|
+
description: 'Not found',
|
|
250
|
+
type: 'object',
|
|
251
|
+
properties: {
|
|
252
|
+
success: { type: 'boolean', example: false },
|
|
253
|
+
message: { type: 'string' },
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
default:
|
|
261
|
+
return {};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import swagger from '@fastify/swagger';
|
|
3
|
+
import swaggerUi from '@fastify/swagger-ui';
|
|
4
|
+
import { config } from '../../config/index.js';
|
|
5
|
+
import { logger } from '../../core/logger.js';
|
|
6
|
+
import type { SwaggerConfig } from './types.js';
|
|
7
|
+
|
|
8
|
+
const defaultConfig: SwaggerConfig = {
|
|
9
|
+
enabled: true,
|
|
10
|
+
route: '/docs',
|
|
11
|
+
title: 'Servcraft API',
|
|
12
|
+
description: 'API documentation generated by Servcraft',
|
|
13
|
+
version: '1.0.0',
|
|
14
|
+
tags: [
|
|
15
|
+
{ name: 'Auth', description: 'Authentication endpoints' },
|
|
16
|
+
{ name: 'Users', description: 'User management endpoints' },
|
|
17
|
+
{ name: 'Health', description: 'Health check endpoints' },
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export async function registerSwagger(
|
|
22
|
+
app: FastifyInstance,
|
|
23
|
+
customConfig?: Partial<SwaggerConfig>
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
const swaggerConfig = { ...defaultConfig, ...customConfig };
|
|
26
|
+
|
|
27
|
+
if (swaggerConfig.enabled === false) {
|
|
28
|
+
logger.info('Swagger documentation disabled');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
await app.register(swagger, {
|
|
33
|
+
openapi: {
|
|
34
|
+
openapi: '3.0.3',
|
|
35
|
+
info: {
|
|
36
|
+
title: swaggerConfig.title,
|
|
37
|
+
description: swaggerConfig.description,
|
|
38
|
+
version: swaggerConfig.version,
|
|
39
|
+
contact: swaggerConfig.contact,
|
|
40
|
+
license: swaggerConfig.license,
|
|
41
|
+
},
|
|
42
|
+
servers: swaggerConfig.servers || [
|
|
43
|
+
{
|
|
44
|
+
url: `http://localhost:${config.server.port}`,
|
|
45
|
+
description: 'Development server',
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
tags: swaggerConfig.tags,
|
|
49
|
+
components: {
|
|
50
|
+
securitySchemes: {
|
|
51
|
+
bearerAuth: {
|
|
52
|
+
type: 'http',
|
|
53
|
+
scheme: 'bearer',
|
|
54
|
+
bearerFormat: 'JWT',
|
|
55
|
+
description: 'Enter your JWT token',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
await app.register(swaggerUi, {
|
|
63
|
+
routePrefix: swaggerConfig.route || '/docs',
|
|
64
|
+
uiConfig: {
|
|
65
|
+
docExpansion: 'list',
|
|
66
|
+
deepLinking: true,
|
|
67
|
+
displayRequestDuration: true,
|
|
68
|
+
filter: true,
|
|
69
|
+
showExtensions: true,
|
|
70
|
+
showCommonExtensions: true,
|
|
71
|
+
},
|
|
72
|
+
staticCSP: true,
|
|
73
|
+
transformStaticCSP: (header) => header,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
logger.info('Swagger documentation registered at /docs');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Helper to generate schema from Zod
|
|
80
|
+
export function zodToJsonSchema(zodSchema: unknown): Record<string, unknown> {
|
|
81
|
+
// This is a simplified version - for full support use zod-to-json-schema package
|
|
82
|
+
return {
|
|
83
|
+
type: 'object',
|
|
84
|
+
properties: {},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Common response schemas
|
|
89
|
+
export const commonResponses = {
|
|
90
|
+
success: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: {
|
|
93
|
+
success: { type: 'boolean', example: true },
|
|
94
|
+
data: { type: 'object' },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
error: {
|
|
98
|
+
type: 'object',
|
|
99
|
+
properties: {
|
|
100
|
+
success: { type: 'boolean', example: false },
|
|
101
|
+
message: { type: 'string' },
|
|
102
|
+
errors: {
|
|
103
|
+
type: 'object',
|
|
104
|
+
additionalProperties: {
|
|
105
|
+
type: 'array',
|
|
106
|
+
items: { type: 'string' },
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
unauthorized: {
|
|
112
|
+
type: 'object',
|
|
113
|
+
properties: {
|
|
114
|
+
success: { type: 'boolean', example: false },
|
|
115
|
+
message: { type: 'string', example: 'Unauthorized' },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
notFound: {
|
|
119
|
+
type: 'object',
|
|
120
|
+
properties: {
|
|
121
|
+
success: { type: 'boolean', example: false },
|
|
122
|
+
message: { type: 'string', example: 'Resource not found' },
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
paginated: {
|
|
126
|
+
type: 'object',
|
|
127
|
+
properties: {
|
|
128
|
+
success: { type: 'boolean', example: true },
|
|
129
|
+
data: {
|
|
130
|
+
type: 'object',
|
|
131
|
+
properties: {
|
|
132
|
+
data: { type: 'array', items: { type: 'object' } },
|
|
133
|
+
meta: {
|
|
134
|
+
type: 'object',
|
|
135
|
+
properties: {
|
|
136
|
+
total: { type: 'number' },
|
|
137
|
+
page: { type: 'number' },
|
|
138
|
+
limit: { type: 'number' },
|
|
139
|
+
totalPages: { type: 'number' },
|
|
140
|
+
hasNextPage: { type: 'boolean' },
|
|
141
|
+
hasPrevPage: { type: 'boolean' },
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Query parameters for pagination
|
|
151
|
+
export const paginationQuery = {
|
|
152
|
+
type: 'object',
|
|
153
|
+
properties: {
|
|
154
|
+
page: { type: 'integer', minimum: 1, default: 1, description: 'Page number' },
|
|
155
|
+
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20, description: 'Items per page' },
|
|
156
|
+
sortBy: { type: 'string', description: 'Field to sort by' },
|
|
157
|
+
sortOrder: { type: 'string', enum: ['asc', 'desc'], default: 'asc', description: 'Sort order' },
|
|
158
|
+
search: { type: 'string', description: 'Search query' },
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// ID parameter
|
|
163
|
+
export const idParam = {
|
|
164
|
+
type: 'object',
|
|
165
|
+
properties: {
|
|
166
|
+
id: { type: 'string', format: 'uuid', description: 'Resource ID' },
|
|
167
|
+
},
|
|
168
|
+
required: ['id'],
|
|
169
|
+
};
|