feat(infisical-pg): added all previous simple server middlewares, swagger support

This commit is contained in:
Akhil Mohan
2024-01-27 12:38:22 +05:30
parent 2243bcb3a4
commit 05205d1eff
17 changed files with 4514 additions and 334 deletions
+19
View File
@@ -0,0 +1,19 @@
import { ZodTypeProvider } from "@app/server/plugins/fastify-zod";
import "fastify";
declare module "fastify" {
interface FastifyRequest {
realIp: string;
}
}
declare global {
type FastifyZodProvider = FastifyInstance<
RawServerDefault,
RawRequestDefaultExpression<RawServerDefault>,
RawReplyDefaultExpression<RawServerDefault>,
FastifyBaseLogger,
ZodTypeProvider
>;
}
-22
View File
@@ -1,22 +0,0 @@
import Fasitfy from "fastify";
const fastify = Fasitfy({
logger: true,
});
// Declare a route
fastify.get("/", async function handler(_request, _reply) {
return { hello: "world changed" };
});
// Run the server!
const main = async () => {
try {
await fastify.listen({ port: 8000 });
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
main();
+28
View File
@@ -0,0 +1,28 @@
import { Logger } from "pino";
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
SALT_ROUNDS: z.coerce.number().default(10),
// TODO(akhilmhdh): will be changed to one
ENCRYPTION_KEY: z.string().optional(),
ROOT_ENCRYPTION_KEY: z.string().optional(),
HTTPS_ENABLED: z
.enum(["true", "false"])
.optional()
.transform((val) => val === "true")
});
let envCfg: Readonly<z.infer<typeof envSchema>>;
export const getConfig = () => envCfg;
// cannot import singleton logger directly as it needs config to load various transport
export const initEnvConfig = (logger: Logger) => {
const parsedEnv = envSchema.safeParse(process.env);
if (!parsedEnv.success) {
logger.error("Invalid environment variables. Check the error below");
logger.error(parsedEnv.error);
process.exit(-1);
}
envCfg = Object.freeze(parsedEnv.data);
};
+1
View File
@@ -0,0 +1 @@
export { initLogger,logger } from "./logger";
+71
View File
@@ -0,0 +1,71 @@
// logger follows a singleton pattern
// easier to use it that's all.
import pino, { Logger } from "pino";
import { z } from "zod";
const logLevelToSeverityLookup: Record<string, string> = {
"10": "TRACE",
"20": "DEBUG",
"30": "INFO",
"40": "WARNING",
"50": "ERROR",
"60": "CRITICAL"
};
// eslint-disable-next-line import/no-mutable-exports
export let logger: Readonly<Logger>;
// akhilmhdh: why this instead of putting it in config right
// reason is to avoid a cyclical condition
// config needs logger to output error when invalid environment is provided
// logger needs config to get aws or other transport cred
// this would make logger independent package
const loggerConfig = z.object({
AWS_CLOUDWATCH_LOG_GROUP_NAME: z.string().default("infisical-log-stream"),
AWS_CLOUDWATCH_LOG_REGION: z.string().default("us-east-1"),
AWS_CLOUDWATCH_LOG_ACCESS_KEY_ID: z.string().min(1).optional(),
AWS_CLOUDWATCH_LOG_ACCESS_KEY_SECRET: z.string().min(1).optional(),
AWS_CLOUDWATCH_LOG_INTERVAL: z.coerce.number().default(1000)
});
export const initLogger = async () => {
const targets: pino.TransportMultiOptions["targets"][number][] = [
{ level: "info", target: "pino/file", options: {} }
];
const cfg = loggerConfig.parse(process.env);
if (cfg.AWS_CLOUDWATCH_LOG_ACCESS_KEY_ID && cfg.AWS_CLOUDWATCH_LOG_ACCESS_KEY_SECRET) {
targets.push({
target: "@serdnam/pino-cloudwatch-transport",
level: "info",
options: {
logGroupName: cfg.AWS_CLOUDWATCH_LOG_GROUP_NAME,
logStreamName: cfg.AWS_CLOUDWATCH_LOG_GROUP_NAME,
awsRegion: cfg.AWS_CLOUDWATCH_LOG_REGION,
awsAccessKeyId: cfg.AWS_CLOUDWATCH_LOG_ACCESS_KEY_ID,
awsSecretAccessKey: cfg.AWS_CLOUDWATCH_LOG_ACCESS_KEY_SECRET,
interval: cfg.AWS_CLOUDWATCH_LOG_INTERVAL
}
});
}
const transport = pino.transport({
targets
});
logger = pino(
{
mixin(_context, level) {
return { severity: logLevelToSeverityLookup[level] || logLevelToSeverityLookup["30"] };
},
level: process.env.PINO_LOG_LEVEL || "info",
formatters: {
bindings: (bindings) => ({
pid: bindings.pid,
hostname: bindings.hostname
// node_version: process.version
})
}
},
transport
);
return logger;
};
+77
View File
@@ -0,0 +1,77 @@
import dotenv from "dotenv";
import fasitfy from "fastify";
import { z } from "zod";
import type { FastifyCookieOptions } from "@fastify/cookie";
import cookie from "@fastify/cookie";
import type { FastifyCorsOptions } from "@fastify/cors";
import cors from "@fastify/cors";
import helmet from "@fastify/helmet";
import type { FastifyRateLimitOptions } from "@fastify/rate-limit";
import ratelimiter from "@fastify/rate-limit";
import { initEnvConfig } from "@lib/config/env";
import { initLogger } from "@lib/logger";
import { globalRateLimiterCfg } from "./config/rateLimiter";
import { serializerCompiler, validatorCompiler, ZodTypeProvider } from "./plugins/fastify-zod";
import { fastifyIp } from "./plugins/ip";
import { fastifySwagger } from "./plugins/swagger";
dotenv.config();
// Run the server!
const main = async () => {
const logger = await initLogger();
initEnvConfig(logger);
const server = fasitfy({
logger,
trustProxy: true
}).withTypeProvider<ZodTypeProvider>();
server.setValidatorCompiler(validatorCompiler);
server.setSerializerCompiler(serializerCompiler);
try {
// TODO(akhilmhdh:pg): change this to environment variable with default
await server.register<FastifyCookieOptions>(cookie, {
secret: "infisical-cookie-secret"
});
await server.register<FastifyCorsOptions>(cors, {
credentials: true,
origin: "http://localhost:3000"
});
// pull ip based on various proxy headers
await server.register(fastifyIp);
// Rate limiters and security headers
await server.register<FastifyRateLimitOptions>(ratelimiter, globalRateLimiterCfg);
await server.register(helmet);
await server.register(fastifySwagger);
// Declare a route
server.route({
method: "GET",
url: "/",
schema: {
response: {
200: z.object({ hello: z.string() })
}
},
handler: () => ({
hello: "world"
})
});
await server.ready();
server.swagger();
await server.listen({ port: 8000 });
} catch (err) {
server.log.error(err);
process.exit(1);
}
};
main();
@@ -0,0 +1,7 @@
import type { RateLimitOptions } from "@fastify/rate-limit";
export const globalRateLimiterCfg: RateLimitOptions = {
timeWindow: 60 * 1000,
max: 100,
keyGenerator: (req) => req.realIp
};
@@ -0,0 +1,142 @@
/* eslint-disable */
// Code taken from https://www.npmjs.com/package/fastify-type-provider-zod
// Full credits goes to https://github.com/turkerdev
// Code taken to keep in in house
import type { FastifySchema, FastifySchemaCompiler, FastifyTypeProvider } from "fastify";
import type { FastifySerializerCompiler } from "fastify/types/schema";
import type { z, ZodAny, ZodTypeAny } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type FreeformRecord = Record<string, any>;
const defaultSkipList = [
"/documentation/",
"/documentation/initOAuth",
"/documentation/json",
"/documentation/uiConfig",
"/documentation/yaml",
"/documentation/*",
"/documentation/static/*"
];
export interface ZodTypeProvider extends FastifyTypeProvider {
output: this["input"] extends ZodTypeAny ? z.infer<this["input"]> : never;
}
interface Schema extends FastifySchema {
hide?: boolean;
}
const zodToJsonSchemaOptions = {
target: "openApi3",
$refStrategy: "none"
} as const;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function hasOwnProperty<T, K extends PropertyKey>(obj: T, prop: K): obj is T & Record<K, any> {
return Object.prototype.hasOwnProperty.call(obj, prop);
}
function resolveSchema(maybeSchema: ZodAny | { properties: ZodAny }): Pick<ZodAny, "safeParse"> {
if (hasOwnProperty(maybeSchema, "safeParse")) {
return maybeSchema;
}
if (hasOwnProperty(maybeSchema, "properties")) {
return maybeSchema.properties;
}
throw new Error(`Invalid schema passed: ${JSON.stringify(maybeSchema)}`);
}
export const createJsonSchemaTransform = ({ skipList }: { skipList: readonly string[] }) => {
return ({ schema, url }: { schema: Schema; url: string }) => {
if (!schema) {
return {
schema,
url
};
}
const { response, headers, querystring, body, params, hide, ...rest } = schema;
const transformed: FreeformRecord = {};
if (skipList.includes(url) || hide) {
transformed.hide = true;
return { schema: transformed, url };
}
const zodSchemas: FreeformRecord = { headers, querystring, body, params };
for (const prop in zodSchemas) {
const zodSchema = zodSchemas[prop];
if (zodSchema) {
transformed[prop] = zodToJsonSchema(zodSchema, zodToJsonSchemaOptions);
}
}
if (response) {
transformed.response = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const prop in response as any) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const schema = resolveSchema((response as any)[prop]);
const transformedResponse = zodToJsonSchema(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schema as any,
zodToJsonSchemaOptions
);
transformed.response[prop] = transformedResponse;
}
}
for (const prop in rest) {
const meta = rest[prop as keyof typeof rest];
if (meta) {
transformed[prop] = meta;
}
}
return { schema: transformed, url };
};
};
export const jsonSchemaTransform = createJsonSchemaTransform({
skipList: defaultSkipList
});
export const validatorCompiler: FastifySchemaCompiler<ZodAny> =
({ schema }) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(data): any => {
try {
return { value: schema.parse(data) };
} catch (error) {
return { error };
}
};
export class ResponseValidationError extends Error {
public details: FreeformRecord;
constructor(validationResult: FreeformRecord) {
super("Response doesn't match the schema");
this.name = "ResponseValidationError";
this.details = validationResult.error;
}
}
export const serializerCompiler: FastifySerializerCompiler<ZodAny | { properties: ZodAny }> =
({ schema: maybeSchema }) =>
(data) => {
const schema: Pick<ZodAny, "safeParse"> = resolveSchema(maybeSchema);
const result = schema.safeParse(data);
if (result.success) {
return JSON.stringify(result.data);
}
throw new ResponseValidationError(result);
};
+31
View File
@@ -0,0 +1,31 @@
import fp from "fastify-plugin";
/*! https://github.com/pbojinov/request-ip/blob/9501cdf6e73059cc70fc6890adb086348d7cca46/src/index.js.
MIT License. 2022 Petar Bojinov - [email protected] */
const headersOrder = [
"x-client-ip", // Most common
"x-forwarded-for", // Mostly used by proxies
"cf-connecting-ip", // Cloudflare
"Cf-Pseudo-IPv4", // Cloudflare
"fastly-client-ip",
"true-client-ip", // Akamai and Cloudflare
"x-real-ip", // Nginx
"x-cluser-client-ip", // Rackspace LB
"forwarded-for",
"x-forwarded",
"forwarded",
"x-appengine-user-ip" // GCP App Engine
];
export const fastifyIp = fp(async (fastify) => {
fastify.decorateRequest("realIp", null);
fastify.addHook("onRequest", async (req) => {
const forwardedIpHeader = headersOrder.find((header) => Boolean(req.headers[header]));
const forwardedIp = forwardedIpHeader ? req.headers[forwardedIpHeader] : undefined;
if (forwardedIp) {
req.realIp = Array.isArray(forwardedIp) ? forwardedIp[0] : forwardedIp;
} else {
req.realIp = req.ip;
}
});
});
+48
View File
@@ -0,0 +1,48 @@
import fp from "fastify-plugin";
import swagger from "@fastify/swagger";
import swaggerUI from "@fastify/swagger-ui";
import { jsonSchemaTransform } from "./fastify-zod";
export const fastifySwagger = fp(async (fastify) => {
await fastify.register(swagger, {
transform: jsonSchemaTransform,
openapi: {
info: {
title: "Infisical API",
description: "List of all available APIs that can be consumed",
version: "0.0.1"
},
servers: [
{
url: "https://app.infisical.com",
description: "Production server"
},
{
url: "http://localhost:8000",
description: "Local server"
}
],
components: {
securitySchemes: {
bearer: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
description: "A service token in Infisical"
},
apiKey: {
type: "apiKey",
in: "header",
name: "X-API-Key",
description: "An API Key in Infisical"
}
}
}
}
});
await fastify.register(swaggerUI, {
routePrefix: "/docs"
});
});