@tmlmobilidade/go-clients-fastify 20260828.1636.54
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/dist/authorization-middleware.d.ts +25 -0
- package/dist/authorization-middleware.js +66 -0
- package/dist/fastify-service.d.ts +90 -0
- package/dist/fastify-service.js +303 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/response/error-response.d.ts +21 -0
- package/dist/response/error-response.js +30 -0
- package/dist/response/index.d.ts +3 -0
- package/dist/response/index.js +3 -0
- package/dist/response/response-options.d.ts +11 -0
- package/dist/response/response-options.js +30 -0
- package/dist/response/success-response.d.ts +32 -0
- package/dist/response/success-response.js +41 -0
- package/package.json +60 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { FastifyReply, type FastifyRequest } from './fastify-service.js';
|
|
2
|
+
import { type Organization, type User } from '@tmlmobilidade/go-types-core';
|
|
3
|
+
import { type ActionsOf, type Permission } from '@tmlmobilidade/go-types-permissions';
|
|
4
|
+
interface AuthorizationPermissionCheck<S extends Permission['scope'] = Permission['scope']> {
|
|
5
|
+
actions: ActionsOf<S>[];
|
|
6
|
+
requireAll?: boolean;
|
|
7
|
+
scope: S;
|
|
8
|
+
}
|
|
9
|
+
declare module 'fastify' {
|
|
10
|
+
interface FastifyRequest {
|
|
11
|
+
me: User;
|
|
12
|
+
organization: Organization;
|
|
13
|
+
permissions: Permission[];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Creates an authorization middleware that validates user authentication and permissions.
|
|
18
|
+
* @param scope The permission scope to check (optional).
|
|
19
|
+
* @param action The permission action(s) to check (optional).
|
|
20
|
+
* @param requireAll Whether all actions must be true or at least one must be true.
|
|
21
|
+
* @returns Fastify middleware function.
|
|
22
|
+
*/
|
|
23
|
+
export declare function authorizationMiddleware(checks: AuthorizationPermissionCheck[]): (request: FastifyRequest, reply: FastifyReply<string>) => Promise<void>;
|
|
24
|
+
export declare function authorizationMiddleware<S extends Permission['scope']>(scope?: S, actions?: ActionsOf<S>[], requireAll?: boolean): (request: FastifyRequest, reply: FastifyReply<string>) => Promise<void>;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/* * */
|
|
2
|
+
import { HTTP_STATUS, HttpException } from '@tmlmobilidade/consts';
|
|
3
|
+
import { AUTH_SESSION_COOKIE_NAME, authProvider } from '@tmlmobilidade/go-providers-auth';
|
|
4
|
+
import { PermissionCatalog } from '@tmlmobilidade/go-types-permissions';
|
|
5
|
+
function isPermissionCheckAllowed(permissionEntries, check) {
|
|
6
|
+
const permissionChecks = check.actions.map(action => PermissionCatalog.hasPermission(permissionEntries, check.scope, action));
|
|
7
|
+
return check.requireAll
|
|
8
|
+
? permissionChecks.every(Boolean)
|
|
9
|
+
: permissionChecks.some(Boolean);
|
|
10
|
+
}
|
|
11
|
+
export function authorizationMiddleware(scopeOrChecks, actions = [], requireAll = false) {
|
|
12
|
+
return async (request, reply) => {
|
|
13
|
+
//
|
|
14
|
+
//
|
|
15
|
+
// Extract the session token from request cookies
|
|
16
|
+
const sessionToken = request.cookies.session_token;
|
|
17
|
+
if (!sessionToken) {
|
|
18
|
+
return reply
|
|
19
|
+
.setCookie(AUTH_SESSION_COOKIE_NAME, '', { httpOnly: true, maxAge: 0, path: '/', sameSite: 'lax', secure: true })
|
|
20
|
+
.send({ data: null, error: 'Session token is missing', statusCode: HTTP_STATUS.UNAUTHORIZED });
|
|
21
|
+
}
|
|
22
|
+
//
|
|
23
|
+
// Get user and permissions from cache or auth provider.
|
|
24
|
+
// Cache is per session token, and valid for 5 minutes.
|
|
25
|
+
// This reduces the number of calls to the auth provider.
|
|
26
|
+
try {
|
|
27
|
+
const userData = await authProvider.getUserFromSessionToken(sessionToken);
|
|
28
|
+
const permissionsData = await authProvider.getPermissionsFromSessionToken(sessionToken);
|
|
29
|
+
const organizationData = await authProvider.getOrganizationFromSessionToken(sessionToken);
|
|
30
|
+
if (!userData || !permissionsData || !organizationData) {
|
|
31
|
+
return reply
|
|
32
|
+
.setCookie(AUTH_SESSION_COOKIE_NAME, '', { httpOnly: true, maxAge: 0, path: '/', sameSite: 'lax', secure: true })
|
|
33
|
+
.send({ data: null, error: 'User, Permissions or Organization not found', statusCode: HTTP_STATUS.UNAUTHORIZED });
|
|
34
|
+
}
|
|
35
|
+
request.me = userData;
|
|
36
|
+
request.permissions = permissionsData;
|
|
37
|
+
request.organization = organizationData;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
console.error('Authorization Middleware Error:', error);
|
|
41
|
+
return reply
|
|
42
|
+
.setCookie(AUTH_SESSION_COOKIE_NAME, '', { httpOnly: true, maxAge: 0, path: '/', sameSite: 'lax', secure: true })
|
|
43
|
+
.send({ data: null, error: 'Authorization Middleware Error', statusCode: HTTP_STATUS.UNAUTHORIZED });
|
|
44
|
+
}
|
|
45
|
+
//
|
|
46
|
+
// Evaluate the retrieved permissions,
|
|
47
|
+
// if scope and actions are provided.
|
|
48
|
+
if (Array.isArray(scopeOrChecks)) {
|
|
49
|
+
const isAllowed = scopeOrChecks.some(check => isPermissionCheckAllowed(request.permissions, check));
|
|
50
|
+
if (!isAllowed) {
|
|
51
|
+
throw new HttpException(HTTP_STATUS.FORBIDDEN, `Insufficient permissions | User: ${request.me._id}`);
|
|
52
|
+
}
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (!scopeOrChecks)
|
|
56
|
+
return;
|
|
57
|
+
const permissionChecks = actions.map(action => PermissionCatalog.hasPermission(request.permissions, scopeOrChecks, action));
|
|
58
|
+
const isAllowed = requireAll
|
|
59
|
+
? permissionChecks.every(Boolean) // all must be true
|
|
60
|
+
: permissionChecks.some(Boolean); // at least one must be true
|
|
61
|
+
if (!isAllowed) {
|
|
62
|
+
throw new HttpException(HTTP_STATUS.FORBIDDEN, `Insufficient permissions | User: ${request.me._id} | Scope: "${scopeOrChecks}" | Actions: [${actions.join(',')}]`);
|
|
63
|
+
}
|
|
64
|
+
//
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import '@fastify/cors';
|
|
2
|
+
import '@fastify/cookie';
|
|
3
|
+
import '@fastify/multipart';
|
|
4
|
+
import { type ApiResponse } from '@tmlmobilidade/go-types-shared';
|
|
5
|
+
import { HttpResponse } from '@tmlmobilidade/utils';
|
|
6
|
+
import { type FastifyInstance as FastifyInstanceType, type FastifyReply as FastifyReplyType } from 'fastify';
|
|
7
|
+
import { type ContextConfigDefault, type FastifyBaseLogger, type FastifySchema, type FastifyServerOptions, type FastifyTypeProviderDefault, type RawReplyDefaultExpression, type RawRequestDefaultExpression, type RawServerBase, type RawServerDefault, type RouteGenericInterface } from 'fastify';
|
|
8
|
+
export { type FastifyRequest } from 'fastify';
|
|
9
|
+
export type FastifyReply<T> = FastifyReplyType<RouteGenericInterface, RawServerBase, RawRequestDefaultExpression<RawServerBase>, RawReplyDefaultExpression<RawServerBase>, ContextConfigDefault, FastifySchema, FastifyTypeProviderDefault, ApiResponse<T> | HttpResponse<T> | ReadableStream>;
|
|
10
|
+
export type FastifyResponse<T> = FastifyReplyType<RouteGenericInterface & {
|
|
11
|
+
Reply: ApiResponse<T> | HttpResponse<T>;
|
|
12
|
+
}, RawServerBase, RawRequestDefaultExpression<RawServerBase>, RawReplyDefaultExpression<RawServerBase>, ContextConfigDefault, FastifySchema, FastifyTypeProviderDefault, ApiResponse<T> | HttpResponse<T>>;
|
|
13
|
+
export type FastifyInstance = FastifyInstanceType<RawServerDefault, RawRequestDefaultExpression, RawReplyDefaultExpression, FastifyBaseLogger, FastifyTypeProviderDefault>;
|
|
14
|
+
/**
|
|
15
|
+
* FastifyServiceOptions interface defines the options for the Fastify server.
|
|
16
|
+
* It extends FastifyServerOptions and adds optional properties for origin and port.
|
|
17
|
+
*/
|
|
18
|
+
export interface FastifyServiceOptions extends FastifyServerOptions {
|
|
19
|
+
/**
|
|
20
|
+
* The host on which the Fastify server will listen.
|
|
21
|
+
* If not provided, it defaults to '0.0.0.0'.
|
|
22
|
+
* @default '0.0.0.0'
|
|
23
|
+
*/
|
|
24
|
+
host?: string;
|
|
25
|
+
/**
|
|
26
|
+
* The module name for the Fastify server.
|
|
27
|
+
* @default 'fastify'
|
|
28
|
+
*/
|
|
29
|
+
module?: string;
|
|
30
|
+
/**
|
|
31
|
+
* The origin for CORS requests.
|
|
32
|
+
* Defaults to `true` if not provided.
|
|
33
|
+
* @default true
|
|
34
|
+
* @example 'https://example.com'
|
|
35
|
+
*/
|
|
36
|
+
origin?: RegExp | string | true;
|
|
37
|
+
/**
|
|
38
|
+
* The port on which the Fastify server will listen.
|
|
39
|
+
* If not provided, it defaults to 5050.
|
|
40
|
+
* @default 5050
|
|
41
|
+
*/
|
|
42
|
+
port?: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* FastifyService is a singleton class that provides a Fastify server instance.
|
|
46
|
+
* It allows for setting up routes, plugins, and starting/stopping the server.
|
|
47
|
+
* This class is designed to be used as a service in a Node.js application.
|
|
48
|
+
* It uses the Fastify framework for building web applications and APIs.
|
|
49
|
+
*/
|
|
50
|
+
export declare class FastifyService {
|
|
51
|
+
private static _instance;
|
|
52
|
+
readonly server: FastifyInstance;
|
|
53
|
+
private readonly options;
|
|
54
|
+
/**
|
|
55
|
+
* Creates an instance of FastifyService.
|
|
56
|
+
* @param options The options for the Fastify server.
|
|
57
|
+
*/
|
|
58
|
+
private constructor();
|
|
59
|
+
/**
|
|
60
|
+
* Gets the singleton instance of FastifyService.
|
|
61
|
+
* @param options The options for the Fastify server.
|
|
62
|
+
* @return The singleton instance of FastifyService.
|
|
63
|
+
*/
|
|
64
|
+
static getInstance(options?: FastifyServiceOptions): FastifyService;
|
|
65
|
+
/**
|
|
66
|
+
* Starts the Fastify server.
|
|
67
|
+
* @return A promise that resolves to the URL of the Fastify server.
|
|
68
|
+
* @throws Will throw an error if the server fails to start.
|
|
69
|
+
*/
|
|
70
|
+
start(moduleName?: string): Promise<string>;
|
|
71
|
+
/**
|
|
72
|
+
* Stops the Fastify server.
|
|
73
|
+
* @return A promise that resolves when the server is stopped.
|
|
74
|
+
*/
|
|
75
|
+
stop(): Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Sets the URL of the Fastify server.
|
|
78
|
+
* @return The URL of the Fastify server.
|
|
79
|
+
*/
|
|
80
|
+
private _setupDefaultRoutes;
|
|
81
|
+
/**
|
|
82
|
+
* Sets up hooks for the Fastify server including error handling and response processing.
|
|
83
|
+
*/
|
|
84
|
+
private _setupHooks;
|
|
85
|
+
/**
|
|
86
|
+
* Sets up the plugins for the Fastify server.
|
|
87
|
+
* @return A promise that resolves when the plugins are set up.
|
|
88
|
+
*/
|
|
89
|
+
private _setupPlugins;
|
|
90
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/naming-convention */
|
|
2
|
+
/* * */
|
|
3
|
+
import '@fastify/cors';
|
|
4
|
+
import '@fastify/cookie';
|
|
5
|
+
import '@fastify/multipart';
|
|
6
|
+
/* * */
|
|
7
|
+
import fastifyCookie from '@fastify/cookie';
|
|
8
|
+
import fastifyCors from '@fastify/cors';
|
|
9
|
+
import oneLineLogger from '@fastify/one-line-logger';
|
|
10
|
+
import { HTTP_STATUS, HttpException } from '@tmlmobilidade/consts';
|
|
11
|
+
import { initSentryNode, Logger } from '@tmlmobilidade/logger';
|
|
12
|
+
import fastify from 'fastify';
|
|
13
|
+
import { sendErrorApiResponse } from './response/error-response.js';
|
|
14
|
+
const defaultFastifyServiceOptions = {
|
|
15
|
+
bodyLimit: 1024 * 1024 * 10, // 10MB
|
|
16
|
+
host: '0.0.0.0',
|
|
17
|
+
logger: true,
|
|
18
|
+
module: 'fastify',
|
|
19
|
+
origin: true,
|
|
20
|
+
port: 5050,
|
|
21
|
+
routerOptions: {
|
|
22
|
+
ignoreTrailingSlash: true,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
const createLoggerOptions = (getModuleName) => ({
|
|
26
|
+
level: 'debug',
|
|
27
|
+
module: getModuleName(),
|
|
28
|
+
stream: oneLineLogger({
|
|
29
|
+
colorize: true, // nice colors,
|
|
30
|
+
colorizeObjects: true,
|
|
31
|
+
messageFormat(log, messageKey, _, extras) {
|
|
32
|
+
const c = extras.colors;
|
|
33
|
+
const moduleName = getModuleName();
|
|
34
|
+
const palette = {
|
|
35
|
+
error: c.redBright,
|
|
36
|
+
highlight: c.yellowBright, // URLs / routes
|
|
37
|
+
message: c.whiteBright,
|
|
38
|
+
method: c.greenBright,
|
|
39
|
+
methodLabel: c.gray,
|
|
40
|
+
path: c.blueBright,
|
|
41
|
+
pathLabel: c.gray,
|
|
42
|
+
pipe: c.cyanBright,
|
|
43
|
+
reqId: c.cyanBright,
|
|
44
|
+
reqIdLabel: c.gray,
|
|
45
|
+
stack: c.red,
|
|
46
|
+
status: c.yellowBright,
|
|
47
|
+
statusLabel: c.gray,
|
|
48
|
+
timestamp: c.cyanBright,
|
|
49
|
+
};
|
|
50
|
+
const colorize = (text) => {
|
|
51
|
+
const urlPattern = /(https?:\/\/[^\s]+)/g;
|
|
52
|
+
const routePattern = /Route "(.+?)"/g;
|
|
53
|
+
const pathPattern = /([A-Z]+):\/[^\s]+/g;
|
|
54
|
+
return text
|
|
55
|
+
.replace(urlPattern, palette.highlight('$&'))
|
|
56
|
+
.replace(routePattern, (_, r) => palette.highlight(`Route "${r}"`))
|
|
57
|
+
.replace(pathPattern, palette.highlight('$&'));
|
|
58
|
+
};
|
|
59
|
+
const safe = (val, fallback = '') => typeof val === 'string' || typeof val === 'number' ? String(val) : fallback;
|
|
60
|
+
const formatMethod = (method) => {
|
|
61
|
+
if (!method)
|
|
62
|
+
return '-----';
|
|
63
|
+
if (method === 'GET' || method === 'PUT')
|
|
64
|
+
return `${method} `;
|
|
65
|
+
return method.padEnd(5, '-');
|
|
66
|
+
};
|
|
67
|
+
const timestamp = new Date(log.time).toLocaleString('pt-PT', {
|
|
68
|
+
day: '2-digit',
|
|
69
|
+
hour: '2-digit',
|
|
70
|
+
minute: '2-digit',
|
|
71
|
+
month: '2-digit',
|
|
72
|
+
second: '2-digit',
|
|
73
|
+
year: 'numeric',
|
|
74
|
+
});
|
|
75
|
+
const reqId = log.reqId ? safe(log.reqId).padEnd(10, ' ') : Array(10).fill('-').join('');
|
|
76
|
+
const statusCode = typeof log.res === 'object' && log.res && 'statusCode' in log.res ? safe(log.res.statusCode).padEnd(3, '-') : '---';
|
|
77
|
+
const method = typeof log.req === 'object' && log.req && 'method' in log.req ? formatMethod(log.req.method ?? '') : '-----';
|
|
78
|
+
const path = typeof log.req === 'object' && log.req && 'url' in log.req ? safe(log.req.url).padEnd(10, ' ') : '-----';
|
|
79
|
+
// Extract error information
|
|
80
|
+
// Pino serializes errors, so log.err is an object with type, message, stack, etc.
|
|
81
|
+
const errorObj = log.err || log.error;
|
|
82
|
+
let errorMessage = safe(log[messageKey]);
|
|
83
|
+
let errorStack;
|
|
84
|
+
if (errorObj) {
|
|
85
|
+
// Pino serialized error object
|
|
86
|
+
errorMessage = errorObj.message || errorMessage;
|
|
87
|
+
errorStack = errorObj.stack;
|
|
88
|
+
}
|
|
89
|
+
else if (log[messageKey] instanceof Error) {
|
|
90
|
+
// Direct Error instance (shouldn't happen with Pino, but just in case)
|
|
91
|
+
errorMessage = log[messageKey].message || errorMessage;
|
|
92
|
+
errorStack = log[messageKey].stack;
|
|
93
|
+
}
|
|
94
|
+
const message = palette.message(colorize(errorMessage));
|
|
95
|
+
// Add stack trace on new lines, indented for readability
|
|
96
|
+
const stackTrace = errorStack ? `\n${palette.stack(errorStack.split('\n').map(line => ` ${line}`).join('\n'))}` : '';
|
|
97
|
+
const parts = [
|
|
98
|
+
palette.timestamp(timestamp),
|
|
99
|
+
palette.reqIdLabel(`reqId: ${palette.reqId(reqId)}`),
|
|
100
|
+
palette.statusLabel(`statusCode: ${palette.status(statusCode)}`),
|
|
101
|
+
palette.methodLabel(`Method: ${palette.method(method)}`),
|
|
102
|
+
palette.pathLabel(`Path: ${palette.path(path)}`),
|
|
103
|
+
message,
|
|
104
|
+
];
|
|
105
|
+
const logMessage = palette.pipe(parts.join(' | ')) + stackTrace;
|
|
106
|
+
const shouldSendToSentry = message !== 'incoming request' && message !== 'request completed';
|
|
107
|
+
if (shouldSendToSentry) {
|
|
108
|
+
Logger.startNodeLogs({
|
|
109
|
+
app: 'api',
|
|
110
|
+
message: message,
|
|
111
|
+
method: method,
|
|
112
|
+
module: moduleName,
|
|
113
|
+
path: path,
|
|
114
|
+
reqId: reqId,
|
|
115
|
+
severity: 'info',
|
|
116
|
+
status: statusCode,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return logMessage;
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
});
|
|
123
|
+
/**
|
|
124
|
+
* FastifyService is a singleton class that provides a Fastify server instance.
|
|
125
|
+
* It allows for setting up routes, plugins, and starting/stopping the server.
|
|
126
|
+
* This class is designed to be used as a service in a Node.js application.
|
|
127
|
+
* It uses the Fastify framework for building web applications and APIs.
|
|
128
|
+
*/
|
|
129
|
+
export class FastifyService {
|
|
130
|
+
//
|
|
131
|
+
static _instance;
|
|
132
|
+
server;
|
|
133
|
+
options;
|
|
134
|
+
/**
|
|
135
|
+
* Creates an instance of FastifyService.
|
|
136
|
+
* @param options The options for the Fastify server.
|
|
137
|
+
*/
|
|
138
|
+
constructor(options) {
|
|
139
|
+
const mergedOptions = { ...defaultFastifyServiceOptions, ...options };
|
|
140
|
+
this.options = mergedOptions;
|
|
141
|
+
this.server = fastify({ ...mergedOptions, logger: createLoggerOptions(() => this.options.module ?? 'fastify') });
|
|
142
|
+
this._setupDefaultRoutes();
|
|
143
|
+
this._setupPlugins();
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Gets the singleton instance of FastifyService.
|
|
147
|
+
* @param options The options for the Fastify server.
|
|
148
|
+
* @return The singleton instance of FastifyService.
|
|
149
|
+
*/
|
|
150
|
+
static getInstance(options) {
|
|
151
|
+
if (!FastifyService._instance) {
|
|
152
|
+
// Create a new instance if it doesn't exist yet
|
|
153
|
+
FastifyService._instance = new FastifyService(options || {});
|
|
154
|
+
FastifyService._instance._setupHooks();
|
|
155
|
+
}
|
|
156
|
+
// Return the existing instance
|
|
157
|
+
return FastifyService._instance;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Starts the Fastify server.
|
|
161
|
+
* @return A promise that resolves to the URL of the Fastify server.
|
|
162
|
+
* @throws Will throw an error if the server fails to start.
|
|
163
|
+
*/
|
|
164
|
+
async start(moduleName) {
|
|
165
|
+
if (moduleName)
|
|
166
|
+
this.options.module = moduleName;
|
|
167
|
+
try {
|
|
168
|
+
await initSentryNode();
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
this.server.log.error({ err: error }, 'Error sending startup log to Sentry.');
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
const serverUrl = await this.server.listen({
|
|
175
|
+
host: this.options.host,
|
|
176
|
+
port: this.options.port,
|
|
177
|
+
});
|
|
178
|
+
this.server.log.info(`Server is running at ${serverUrl}`);
|
|
179
|
+
return serverUrl;
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
this.server.log.error({ err: error }, 'Error starting server.');
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Stops the Fastify server.
|
|
188
|
+
* @return A promise that resolves when the server is stopped.
|
|
189
|
+
*/
|
|
190
|
+
async stop() {
|
|
191
|
+
try {
|
|
192
|
+
await this.server.close();
|
|
193
|
+
console.log('Fastify server stopped.');
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
this.server.log.error({ err: error }, error instanceof Error ? error.message : 'Error stopping server');
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Sets the URL of the Fastify server.
|
|
202
|
+
* @return The URL of the Fastify server.
|
|
203
|
+
*/
|
|
204
|
+
_setupDefaultRoutes() {
|
|
205
|
+
this.server.get('/', (req, res) => {
|
|
206
|
+
res.send('Jusi was here!');
|
|
207
|
+
});
|
|
208
|
+
this.server.get('/health', (_, res) => {
|
|
209
|
+
res.status(HTTP_STATUS.OK).send({ status: 'ok' });
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Sets up hooks for the Fastify server including error handling and response processing.
|
|
214
|
+
*/
|
|
215
|
+
_setupHooks() {
|
|
216
|
+
/**
|
|
217
|
+
* Decodes URI-encoded `id` path params so encoded slashes (e.g. `%2F`) are
|
|
218
|
+
* available as literal characters in route handlers.
|
|
219
|
+
*/
|
|
220
|
+
this.server.addHook('preHandler', (request, _, done) => {
|
|
221
|
+
const params = request.params;
|
|
222
|
+
if (params.id !== undefined) {
|
|
223
|
+
try {
|
|
224
|
+
params.id = decodeURIComponent(params.id);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
// Malformed URI sequence — keep original value
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
done();
|
|
231
|
+
});
|
|
232
|
+
/**
|
|
233
|
+
* Sets a global error handler for the Fastify server instance.
|
|
234
|
+
* This handler checks if the error is an instance of HttpException.
|
|
235
|
+
* If so, it sends a response with the appropriate status code and error message.
|
|
236
|
+
* This ensures consistent error responses for HTTP exceptions throughout the application.
|
|
237
|
+
*/
|
|
238
|
+
this.server.setErrorHandler((error, request, reply) => {
|
|
239
|
+
// Log the error with full stack trace
|
|
240
|
+
const errorMessage = error instanceof Error ? error.message : 'Unhandled error';
|
|
241
|
+
this.server.log.error({ err: error }, errorMessage);
|
|
242
|
+
// Handle HttpException errors
|
|
243
|
+
if (error instanceof HttpException) {
|
|
244
|
+
if (error.statusCode === HTTP_STATUS.INTERNAL_SERVER_ERROR) {
|
|
245
|
+
Logger.issue({ context: { action: 'errorHandler', feature: this.options.module, request, value: request.body }, level: 'error', messageOrError: error });
|
|
246
|
+
}
|
|
247
|
+
reply
|
|
248
|
+
.status(error.statusCode)
|
|
249
|
+
.send({
|
|
250
|
+
data: undefined,
|
|
251
|
+
error: error.message,
|
|
252
|
+
statusCode: error.statusCode,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
Logger.issue({ context: { action: 'errorHandler', feature: this.options.module, request, value: request.body }, level: 'error', messageOrError: 'Internal server error' });
|
|
257
|
+
return sendErrorApiResponse(reply, {
|
|
258
|
+
error: 'Internal server error',
|
|
259
|
+
status_code: '500',
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
/**
|
|
264
|
+
* Adds an 'onSend' hook to the Fastify server instance.
|
|
265
|
+
* This hook intercepts every outgoing response before it is sent.
|
|
266
|
+
* It parses the payload as a JSON object (assuming it matches the HttpResponse<T> structure),
|
|
267
|
+
* and sets the HTTP status code of the reply to the value of 'statusCode' in the payload,
|
|
268
|
+
* defaulting to HTTP_STATUS.OK if not present.
|
|
269
|
+
* This ensures that the HTTP status code in the response matches the statusCode property
|
|
270
|
+
* in the application's response payload, providing consistent status handling.
|
|
271
|
+
*/
|
|
272
|
+
this.server.addHook('onSend', (_, reply, payload, done) => {
|
|
273
|
+
try {
|
|
274
|
+
const payloadJson = JSON.parse(payload);
|
|
275
|
+
reply.code(payloadJson.statusCode ?? HTTP_STATUS.OK);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// Do nothing
|
|
279
|
+
}
|
|
280
|
+
finally {
|
|
281
|
+
done();
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Sets up the plugins for the Fastify server.
|
|
287
|
+
* @return A promise that resolves when the plugins are set up.
|
|
288
|
+
*/
|
|
289
|
+
async _setupPlugins() {
|
|
290
|
+
// CORS plugin
|
|
291
|
+
await this.server.register(fastifyCors, {
|
|
292
|
+
credentials: true,
|
|
293
|
+
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE'],
|
|
294
|
+
origin: this.options.origin,
|
|
295
|
+
});
|
|
296
|
+
// Cookie plugin
|
|
297
|
+
await this.server.register(fastifyCookie);
|
|
298
|
+
// Multipart plugin
|
|
299
|
+
// await this.server.register(fastifyMultipart, {
|
|
300
|
+
// limits: { fileSize: this.options.bodyLimit },
|
|
301
|
+
// });
|
|
302
|
+
}
|
|
303
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type FastifyReply } from '../fastify-service.js';
|
|
2
|
+
import { type ApiResponseError } from '@tmlmobilidade/go-types-shared';
|
|
3
|
+
import { type ApiResponseOptions } from './response-options.js';
|
|
4
|
+
/**
|
|
5
|
+
* Options for sending an error API response.
|
|
6
|
+
*/
|
|
7
|
+
interface SendErrorResponseOptions extends ApiResponseOptions {
|
|
8
|
+
/**
|
|
9
|
+
* The error message to send in the error response.
|
|
10
|
+
*/
|
|
11
|
+
error: ApiResponseError['error'];
|
|
12
|
+
/**
|
|
13
|
+
* The status code to send in the error response.
|
|
14
|
+
*/
|
|
15
|
+
status_code: ApiResponseError['status_code'];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* A function that sends an error HTTP response.
|
|
19
|
+
*/
|
|
20
|
+
export declare function sendErrorApiResponse<T>(reply: FastifyReply<T>, options: SendErrorResponseOptions): import("fastify").FastifyReply<import("fastify").RouteGenericInterface, import("fastify").RawServerBase, import("fastify").RawRequestDefaultExpression<import("fastify").RawServerBase>, import("fastify").RawReplyDefaultExpression<import("fastify").RawServerBase>, unknown, import("fastify").FastifySchema, import("fastify").FastifyTypeProviderDefault, unknown>;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/* * */
|
|
2
|
+
import { Dates } from '@tmlmobilidade/go-utils-dates';
|
|
3
|
+
import { getCacheControlHeader } from './response-options.js';
|
|
4
|
+
/**
|
|
5
|
+
* A function that sends an error HTTP response.
|
|
6
|
+
*/
|
|
7
|
+
export function sendErrorApiResponse(reply, options) {
|
|
8
|
+
//
|
|
9
|
+
//
|
|
10
|
+
// Set the status code
|
|
11
|
+
if (!options.status_code)
|
|
12
|
+
throw new Error('Status code is required in sendErrorApiResponse()');
|
|
13
|
+
if (!options.error)
|
|
14
|
+
throw new Error('Error message is required in sendErrorApiResponse()');
|
|
15
|
+
reply.status(Number(options.status_code));
|
|
16
|
+
//
|
|
17
|
+
// Set the Cache-Control header
|
|
18
|
+
const cacheControlHeader = getCacheControlHeader(options?.max_age);
|
|
19
|
+
if (cacheControlHeader)
|
|
20
|
+
reply.header('Cache-Control', cacheControlHeader);
|
|
21
|
+
//
|
|
22
|
+
// Return with the prepared response
|
|
23
|
+
const response = {
|
|
24
|
+
data: null,
|
|
25
|
+
error: options.error,
|
|
26
|
+
status_code: options.status_code,
|
|
27
|
+
timestamp: Dates.now('local').unix_timestamp,
|
|
28
|
+
};
|
|
29
|
+
return reply.send(response);
|
|
30
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ApiResponseOptions {
|
|
2
|
+
/**
|
|
3
|
+
* The maximum age of the response in seconds.
|
|
4
|
+
* @default undefined
|
|
5
|
+
*/
|
|
6
|
+
max_age?: '1d' | '1h' | '1m' | '3s' | '5m' | '30m' | '30s' | null;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Receives a max_age value and returns the Cache-Control header.
|
|
10
|
+
*/
|
|
11
|
+
export declare function getCacheControlHeader(maxAge: ApiResponseOptions['max_age']): string;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/* * */
|
|
2
|
+
/**
|
|
3
|
+
* Receives a max_age value and returns the Cache-Control header.
|
|
4
|
+
*/
|
|
5
|
+
export function getCacheControlHeader(maxAge) {
|
|
6
|
+
// Return empty if no max_age is provided
|
|
7
|
+
if (!maxAge)
|
|
8
|
+
return '';
|
|
9
|
+
// Return the Cache-Control header
|
|
10
|
+
let maxAgeSeconds;
|
|
11
|
+
if (typeof maxAge === 'number')
|
|
12
|
+
maxAgeSeconds = maxAge;
|
|
13
|
+
else if (maxAge === '3s')
|
|
14
|
+
maxAgeSeconds = 3;
|
|
15
|
+
else if (maxAge === '30s')
|
|
16
|
+
maxAgeSeconds = 30;
|
|
17
|
+
else if (maxAge === '1m')
|
|
18
|
+
maxAgeSeconds = 60;
|
|
19
|
+
else if (maxAge === '5m')
|
|
20
|
+
maxAgeSeconds = 300;
|
|
21
|
+
else if (maxAge === '30m')
|
|
22
|
+
maxAgeSeconds = 1800;
|
|
23
|
+
else if (maxAge === '1h')
|
|
24
|
+
maxAgeSeconds = 3600;
|
|
25
|
+
else if (maxAge === '1d')
|
|
26
|
+
maxAgeSeconds = 86400;
|
|
27
|
+
else
|
|
28
|
+
throw new Error(`Invalid max_age: ${maxAge}`);
|
|
29
|
+
return `public, max-age=${maxAgeSeconds}`;
|
|
30
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type FastifyReply } from '../fastify-service.js';
|
|
2
|
+
import { type ApiResponseSuccess } from '@tmlmobilidade/go-types-shared';
|
|
3
|
+
import { type ApiResponseOptions } from './response-options.js';
|
|
4
|
+
/**
|
|
5
|
+
* Options for sending a successful API response.
|
|
6
|
+
*/
|
|
7
|
+
interface SendSuccessApiResponseOptions<T> extends ApiResponseOptions {
|
|
8
|
+
/**
|
|
9
|
+
* The status code to send in the successful response.
|
|
10
|
+
* Defaults to `200`.
|
|
11
|
+
*/
|
|
12
|
+
status_code?: ApiResponseSuccess<T>['status_code'];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A function that sends a successful HTTP response.
|
|
16
|
+
* @example
|
|
17
|
+
* ```ts
|
|
18
|
+
* sendSuccessApiResponse(reply, { message: 'Hello, world!' });
|
|
19
|
+
* ```
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* // With a different status code (e.g. 201 Created)
|
|
23
|
+
* sendSuccessApiResponse<Item[]>(reply, queryResult, { status_code: '201' });
|
|
24
|
+
* ```
|
|
25
|
+
* @example
|
|
26
|
+
* ```ts
|
|
27
|
+
* // With a cache control header (e.g. 1 minute)
|
|
28
|
+
* sendSuccessApiResponse<PublicResource>(reply, queryResult, { max_age: 60 });
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export declare function sendSuccessApiResponse<T>(reply: FastifyReply<T>, data: T, options?: SendSuccessApiResponseOptions<T>): import("fastify").FastifyReply<import("fastify").RouteGenericInterface, import("fastify").RawServerBase, import("fastify").RawRequestDefaultExpression<import("fastify").RawServerBase>, import("fastify").RawReplyDefaultExpression<import("fastify").RawServerBase>, unknown, import("fastify").FastifySchema, import("fastify").FastifyTypeProviderDefault, unknown>;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/* * */
|
|
2
|
+
import { Dates } from '@tmlmobilidade/go-utils-dates';
|
|
3
|
+
import { getCacheControlHeader } from './response-options.js';
|
|
4
|
+
/**
|
|
5
|
+
* A function that sends a successful HTTP response.
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* sendSuccessApiResponse(reply, { message: 'Hello, world!' });
|
|
9
|
+
* ```
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* // With a different status code (e.g. 201 Created)
|
|
13
|
+
* sendSuccessApiResponse<Item[]>(reply, queryResult, { status_code: '201' });
|
|
14
|
+
* ```
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* // With a cache control header (e.g. 1 minute)
|
|
18
|
+
* sendSuccessApiResponse<PublicResource>(reply, queryResult, { max_age: 60 });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export function sendSuccessApiResponse(reply, data, options) {
|
|
22
|
+
//
|
|
23
|
+
//
|
|
24
|
+
// Set the status code
|
|
25
|
+
const statusCodeValue = options?.status_code || '200';
|
|
26
|
+
reply.status(Number(statusCodeValue));
|
|
27
|
+
//
|
|
28
|
+
// Set the Cache-Control header
|
|
29
|
+
const cacheControlHeader = getCacheControlHeader(options?.max_age);
|
|
30
|
+
if (cacheControlHeader)
|
|
31
|
+
reply.header('Cache-Control', cacheControlHeader);
|
|
32
|
+
//
|
|
33
|
+
// Return with the prepared response
|
|
34
|
+
const response = {
|
|
35
|
+
data,
|
|
36
|
+
error: null,
|
|
37
|
+
status_code: statusCodeValue,
|
|
38
|
+
timestamp: Dates.now('local').unix_timestamp,
|
|
39
|
+
};
|
|
40
|
+
return reply.send(response);
|
|
41
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tmlmobilidade/go-clients-fastify",
|
|
3
|
+
"version": "20260828.1636.54",
|
|
4
|
+
"author": {
|
|
5
|
+
"email": "iso@tmlmobilidade.pt",
|
|
6
|
+
"name": "TML-ISO"
|
|
7
|
+
},
|
|
8
|
+
"license": "AGPL-3.0-or-later",
|
|
9
|
+
"homepage": "https://go.tmlmobilidade.pt",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/tmlmobilidade/go/issues"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/tmlmobilidade/go.git"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"public transit",
|
|
19
|
+
"tml",
|
|
20
|
+
"transportes metropolitanos de lisboa",
|
|
21
|
+
"go"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"files": [
|
|
28
|
+
"dist"
|
|
29
|
+
],
|
|
30
|
+
"main": "./dist/index.js",
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc && resolve-tspaths",
|
|
34
|
+
"lint": "eslint ./src && tsc --noEmit",
|
|
35
|
+
"lint:fix": "eslint ./src --fix",
|
|
36
|
+
"watch": "tsc-watch --onSuccess 'resolve-tspaths'"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@fastify/cookie": "11.1.2",
|
|
40
|
+
"@fastify/cors": "11.3.0",
|
|
41
|
+
"@fastify/multipart": "10.1.0",
|
|
42
|
+
"@fastify/one-line-logger": "2.1.0",
|
|
43
|
+
"@tmlmobilidade/consts": "*",
|
|
44
|
+
"@tmlmobilidade/go-providers-auth": "*",
|
|
45
|
+
"@tmlmobilidade/go-types-core": "*",
|
|
46
|
+
"@tmlmobilidade/go-types-permissions": "*",
|
|
47
|
+
"@tmlmobilidade/go-types-shared": "*",
|
|
48
|
+
"@tmlmobilidade/go-utils-dates": "*",
|
|
49
|
+
"@tmlmobilidade/logger": "*",
|
|
50
|
+
"@tmlmobilidade/utils": "*",
|
|
51
|
+
"fastify": "5.11.2"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@tmlmobilidade/go-utils-tsconfig": "*",
|
|
55
|
+
"@types/node": "26.1.2",
|
|
56
|
+
"resolve-tspaths": "0.8.23",
|
|
57
|
+
"tsc-watch": "7.2.1",
|
|
58
|
+
"typescript": "6.0.3"
|
|
59
|
+
}
|
|
60
|
+
}
|