Merged in fix/redis-api-model-validation (pull request #2000)
* fix: model response validation for 204 * log: add more logging when deleting keys * add version to health endpoint Approved-by: Anton Gunnarsson
This commit is contained in:
@@ -2,6 +2,7 @@ import { Environment, EnvironmentVar } from '../types.bicep'
|
|||||||
|
|
||||||
targetScope = 'subscription'
|
targetScope = 'subscription'
|
||||||
|
|
||||||
|
param version string
|
||||||
param environment Environment
|
param environment Environment
|
||||||
param containerImageTag string
|
param containerImageTag string
|
||||||
param redisConnection string
|
param redisConnection string
|
||||||
@@ -45,6 +46,7 @@ module containerApp 'containerApp.bicep' = {
|
|||||||
{ name: 'SENTRY_DSN', value: sentryDSN }
|
{ name: 'SENTRY_DSN', value: sentryDSN }
|
||||||
{ name: 'SENTRY_ENABLED', value: sentryEnabled }
|
{ name: 'SENTRY_ENABLED', value: sentryEnabled }
|
||||||
{ name: 'SENTRY_TRACE_SAMPLE_RATE', value: sentryTraceSampleRate }
|
{ name: 'SENTRY_TRACE_SAMPLE_RATE', value: sentryTraceSampleRate }
|
||||||
|
{ name: 'VERSION', value: version }
|
||||||
|
|
||||||
{ name: 'timestamp', value: timestamp }
|
{ name: 'timestamp', value: timestamp }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ targetScope = 'subscription'
|
|||||||
|
|
||||||
param environment Environment
|
param environment Environment
|
||||||
param containerImageTag string = 'latest'
|
param containerImageTag string = 'latest'
|
||||||
|
param version string
|
||||||
|
|
||||||
param primaryApiKey string
|
param primaryApiKey string
|
||||||
param secondaryApiKey string
|
param secondaryApiKey string
|
||||||
@@ -51,5 +52,6 @@ module containerApp 'app/main.bicep' = {
|
|||||||
sentryDSN: sentryDSN
|
sentryDSN: sentryDSN
|
||||||
sentryEnabled: sentryEnabled
|
sentryEnabled: sentryEnabled
|
||||||
sentryTraceSampleRate: sentryTraceSampleRate
|
sentryTraceSampleRate: sentryTraceSampleRate
|
||||||
|
version: version
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const env = createEnv({
|
|||||||
},
|
},
|
||||||
createFinalSchema: (shape) => {
|
createFinalSchema: (shape) => {
|
||||||
return z.object(shape).transform((env, ctx) => {
|
return z.object(shape).transform((env, ctx) => {
|
||||||
if (!env.SENTRY_ENABLED || !env.SENTRY_DSN) {
|
if (env.SENTRY_ENABLED && !env.SENTRY_DSN) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message:
|
message:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const cacheRoutes = new Elysia({ prefix: "/cache" })
|
|||||||
"/",
|
"/",
|
||||||
async ({ query: { key }, status }) => {
|
async ({ query: { key }, status }) => {
|
||||||
key = validateKey(key);
|
key = validateKey(key);
|
||||||
cacheRouteLogger.info("GET /cache", key);
|
cacheRouteLogger.info(`GET /cache ${key}`);
|
||||||
|
|
||||||
const value = await redis.get(key);
|
const value = await redis.get(key);
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -48,7 +48,7 @@ export const cacheRoutes = new Elysia({ prefix: "/cache" })
|
|||||||
"/",
|
"/",
|
||||||
async ({ query: { key }, body, status, set }) => {
|
async ({ query: { key }, body, status, set }) => {
|
||||||
key = validateKey(key);
|
key = validateKey(key);
|
||||||
cacheRouteLogger.info("PUT /cache", key);
|
cacheRouteLogger.info(`PUT /cache ${key}`);
|
||||||
|
|
||||||
if (!body.ttl || body.ttl < 0) {
|
if (!body.ttl || body.ttl < 0) {
|
||||||
return status("Bad Request", "ttl is required");
|
return status("Bad Request", "ttl is required");
|
||||||
@@ -56,35 +56,45 @@ export const cacheRoutes = new Elysia({ prefix: "/cache" })
|
|||||||
|
|
||||||
await redis.set(key, JSON.stringify(body.data), "EX", body.ttl);
|
await redis.set(key, JSON.stringify(body.data), "EX", body.ttl);
|
||||||
|
|
||||||
return status(204, void 0);
|
set.status = 204;
|
||||||
|
return undefined;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
body: t.Object({ data: t.Any(), ttl: t.Number() }),
|
body: t.Object({ data: t.Any(), ttl: t.Number() }),
|
||||||
query: QUERY_TYPE,
|
query: QUERY_TYPE,
|
||||||
response: { 204: t.Void(), 400: t.String() },
|
response: { 204: t.Undefined(), 400: t.String() },
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.delete(
|
.delete(
|
||||||
"/",
|
"/",
|
||||||
async ({ query: { key, fuzzy }, status }) => {
|
async ({ query: { key, fuzzy }, set }) => {
|
||||||
key = validateKey(key);
|
key = validateKey(key);
|
||||||
cacheRouteLogger.info("DELETE /cache", key, { fuzzy });
|
cacheRouteLogger.info(
|
||||||
|
`DELETE /cache ${key} ${fuzzy ? "fuzzy" : ""}`
|
||||||
|
);
|
||||||
|
|
||||||
if (fuzzy) {
|
if (fuzzy) {
|
||||||
await deleteWithPattern(`*${key}*`);
|
await deleteWithPattern(`*${key}*`);
|
||||||
} else {
|
} else {
|
||||||
await redis.del(key);
|
const deletedKeys = await redis.del(key);
|
||||||
cacheRouteLogger.info("Deleted key: ", key);
|
if (deletedKeys === 0) {
|
||||||
|
cacheRouteLogger.info(
|
||||||
|
`Key '${key}' not found, nothing deleted`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
cacheRouteLogger.info(`Deleted key '${key}'`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return status(204, void 0);
|
set.status = 204;
|
||||||
|
return undefined;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
query: t.Object({
|
query: t.Object({
|
||||||
...QUERY_TYPE.properties,
|
...QUERY_TYPE.properties,
|
||||||
...t.Object({ fuzzy: t.Optional(t.Boolean()) }).properties,
|
...t.Object({ fuzzy: t.Optional(t.Boolean()) }).properties,
|
||||||
}),
|
}),
|
||||||
response: { 204: t.Void(), 400: t.String() },
|
response: { 204: t.Undefined(), 400: t.String() },
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -97,9 +107,6 @@ function validateKey(key: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsedKey.includes("*")) {
|
|
||||||
throw new ModelValidationError("Key cannot contain wildcards");
|
|
||||||
}
|
|
||||||
if (parsedKey.includes("*")) {
|
if (parsedKey.includes("*")) {
|
||||||
throw new ModelValidationError("Key cannot contain wildcards");
|
throw new ModelValidationError("Key cannot contain wildcards");
|
||||||
}
|
}
|
||||||
@@ -128,7 +135,13 @@ async function deleteWithPattern(pattern: string) {
|
|||||||
} while (cursor !== "0");
|
} while (cursor !== "0");
|
||||||
|
|
||||||
if (keys.length > 0) {
|
if (keys.length > 0) {
|
||||||
await redis.del(...keys);
|
const deleteCount = await redis.del(...keys);
|
||||||
|
keys.map((key, idx) => {
|
||||||
|
cacheRouteLogger.info(
|
||||||
|
`Deleted key ${idx + 1}/${deleteCount}: ${key}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
cacheRouteLogger.info(`Deleted number of keys: ${deleteCount}`);
|
||||||
}
|
}
|
||||||
cacheRouteLogger.info("Deleted number of keys: ", keys.length);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,55 @@
|
|||||||
import Elysia, { t } from "elysia";
|
import Elysia, { t } from "elysia";
|
||||||
|
|
||||||
import { redis } from "@/services/redis";
|
import { redis } from "@/services/redis";
|
||||||
import { baseLogger } from "@/utils/logger";
|
import { baseLogger, loggerModule } from "@/utils/logger";
|
||||||
|
import { env } from "@/env";
|
||||||
|
|
||||||
|
const healthLogger = baseLogger.child({
|
||||||
|
module: "health",
|
||||||
|
});
|
||||||
|
|
||||||
export const healthRoutes = new Elysia().get(
|
export const healthRoutes = new Elysia().get(
|
||||||
"/health",
|
"/health",
|
||||||
async ({ set, status }) => {
|
async ({ status }) => {
|
||||||
const perf = performance.now();
|
const perf = performance.now();
|
||||||
|
let healthy = true;
|
||||||
try {
|
try {
|
||||||
await redis.ping();
|
await redis.ping();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
baseLogger.error("Redis connection error:", e);
|
healthLogger.error("Redis connection error:", e);
|
||||||
console.log("Redis connection error:", e);
|
|
||||||
|
|
||||||
return status(503, { healthy: false });
|
healthy = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const duration = performance.now() - perf;
|
const duration = performance.now() - perf;
|
||||||
baseLogger.info(`Service healthy: ${duration.toFixed(2)} ms`);
|
const durationString = `${duration.toFixed(2)} ms`;
|
||||||
|
|
||||||
return { healthy: true };
|
if (!healthy) {
|
||||||
|
healthLogger.error("Health check failed");
|
||||||
|
return status(503, {
|
||||||
|
healthy,
|
||||||
|
version: env.VERSION,
|
||||||
|
duration: durationString,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
healthy,
|
||||||
|
version: env.VERSION,
|
||||||
|
duration: durationString,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
response: {
|
response: {
|
||||||
200: t.Object({
|
200: t.Object({
|
||||||
healthy: t.Boolean(),
|
healthy: t.Boolean(),
|
||||||
|
version: t.String(),
|
||||||
|
duration: t.String(),
|
||||||
}),
|
}),
|
||||||
503: t.Object({
|
503: t.Object({
|
||||||
healthy: t.Boolean(),
|
healthy: t.Boolean(),
|
||||||
|
version: t.String(),
|
||||||
|
duration: t.String(),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user