Merge branch 'dev' into feat/232-assignments-pagina-ui-ux
Merge dev into feat/232-assignments-pagina-ui-ux
This commit is contained in:
		
						commit
						96076844a5
					
				
					 56 changed files with 1265 additions and 867 deletions
				
			
		
							
								
								
									
										44
									
								
								backend/src/caching.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								backend/src/caching.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,44 @@ | |||
| import { getLogger } from './logging/initalize.js'; | ||||
| import { envVars, getEnvVar } from './util/envVars.js'; | ||||
| import { Client } from 'memjs'; | ||||
| 
 | ||||
| export type CacheClient = Client; | ||||
| 
 | ||||
| let cacheClient: CacheClient; | ||||
| 
 | ||||
| async function initializeClient(): Promise<CacheClient> { | ||||
|     if (cacheClient !== undefined) { | ||||
|         return cacheClient; | ||||
|     } | ||||
| 
 | ||||
|     const cachingHost = getEnvVar(envVars.CacheHost); | ||||
|     const cachingPort = getEnvVar(envVars.CachePort); | ||||
|     const cachingUrl = `${cachingHost}:${cachingPort}`; | ||||
| 
 | ||||
|     if (cachingHost === '') { | ||||
|         return cacheClient; | ||||
|     } | ||||
| 
 | ||||
|     cacheClient = Client.create(cachingUrl); | ||||
| 
 | ||||
|     getLogger().info(`Memcached client initialized at ${cachingUrl}`); | ||||
| 
 | ||||
|     return cacheClient; | ||||
| } | ||||
| 
 | ||||
| export async function getCacheClient(): Promise<CacheClient> { | ||||
|     cacheClient ||= await initializeClient(); | ||||
|     return cacheClient; | ||||
| } | ||||
| 
 | ||||
| export async function checkCachingHealth(): Promise<boolean> { | ||||
|     try { | ||||
|         const client = await getCacheClient(); | ||||
|         await client.set('health', Buffer.from('ok'), { expires: 60 }); | ||||
|         const reply = await cacheClient.get('health'); | ||||
|         return reply?.value?.toString() === 'ok'; | ||||
|     } catch (error) { | ||||
|         getLogger().error('Caching Health Check Failed:', error); | ||||
|         return false; | ||||
|     } | ||||
| } | ||||
|  | @ -29,7 +29,8 @@ function initializeLogger(): Logger { | |||
|         format: format.combine(format.cli(), format.simple()), | ||||
|     }); | ||||
| 
 | ||||
|     if (getEnvVar(envVars.RunMode) === 'dev') { | ||||
|     const runMode = getEnvVar(envVars.RunMode); | ||||
|     if (runMode === 'dev' || runMode.includes('test')) { | ||||
|         logger = createLogger({ | ||||
|             transports: [consoleTransport], | ||||
|         }); | ||||
|  |  | |||
|  | @ -7,12 +7,17 @@ let orm: MikroORM | undefined; | |||
| export async function initORM(testingMode = false): Promise<MikroORM<IDatabaseDriver, EntityManager>> { | ||||
|     const logger: Logger = getLogger(); | ||||
| 
 | ||||
|     logger.info('Initializing ORM'); | ||||
|     logger.debug('MikroORM config is', config); | ||||
|     const options = config(testingMode); | ||||
| 
 | ||||
|     logger.info('MikroORM config is', options); | ||||
| 
 | ||||
|     logger.info('Initializing ORM'); | ||||
|     orm = await MikroORM.init(options); | ||||
|     logger.info('MikroORM initialized'); | ||||
| 
 | ||||
|     orm = await MikroORM.init(config(testingMode)); | ||||
|     // Update the database scheme if necessary and enabled.
 | ||||
|     if (getEnvVar(envVars.DbUpdate)) { | ||||
|         logger.info('MikroORM: Updating database schema'); | ||||
|         await orm.schema.updateSchema(); | ||||
|     } else { | ||||
|         const diff = await orm.schema.getUpdateSchemaSQL(); | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| import { DWENGO_API_BASE } from '../config.js'; | ||||
| import { fetchWithLogging } from '../util/api-helper.js'; | ||||
| import { fetchRemote } from '../util/api-helper.js'; | ||||
| 
 | ||||
| import { | ||||
|     FilteredLearningObject, | ||||
|  | @ -39,10 +39,7 @@ function filterData(data: LearningObjectMetadata, htmlUrl: string): FilteredLear | |||
|  */ | ||||
| export async function getLearningObjectById(hruid: string, language: string): Promise<FilteredLearningObject | null> { | ||||
|     const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata?hruid=${hruid}&language=${language}`; | ||||
|     const metadata = await fetchWithLogging<LearningObjectMetadata>( | ||||
|         metadataUrl, | ||||
|         `Metadata for Learning Object HRUID "${hruid}" (language ${language})` | ||||
|     ); | ||||
|     const metadata = await fetchRemote<LearningObjectMetadata>(metadataUrl, `Metadata for Learning Object HRUID "${hruid}" (language ${language})`); | ||||
| 
 | ||||
|     if (!metadata) { | ||||
|         getLogger().error(`⚠️ WARNING: Learning object "${hruid}" not found.`); | ||||
|  |  | |||
|  | @ -1,5 +1,5 @@ | |||
| import { DWENGO_API_BASE } from '../../config.js'; | ||||
| import { fetchWithLogging } from '../../util/api-helper.js'; | ||||
| import { fetchRemote } from '../../util/api-helper.js'; | ||||
| import dwengoApiLearningPathProvider from '../learning-paths/dwengo-api-learning-path-provider.js'; | ||||
| import { LearningObjectProvider } from './learning-object-provider.js'; | ||||
| import { getLogger, Logger } from '../../logging/initalize.js'; | ||||
|  | @ -88,7 +88,7 @@ const dwengoApiLearningObjectProvider: LearningObjectProvider = { | |||
|      */ | ||||
|     async getLearningObjectById(id: LearningObjectIdentifierDTO): Promise<FilteredLearningObject | null> { | ||||
|         const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata`; | ||||
|         const metadata = await fetchWithLogging<LearningObjectMetadata>( | ||||
|         const metadata = await fetchRemote<LearningObjectMetadata>( | ||||
|             metadataUrl, | ||||
|             `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, | ||||
|             { | ||||
|  | @ -124,7 +124,7 @@ const dwengoApiLearningObjectProvider: LearningObjectProvider = { | |||
|      */ | ||||
|     async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null> { | ||||
|         const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw`; | ||||
|         const html = await fetchWithLogging<string>(htmlUrl, `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, { | ||||
|         const html = await fetchRemote<string>(htmlUrl, `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, { | ||||
|             params: { ...id }, | ||||
|         }); | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,4 +1,4 @@ | |||
| import { fetchWithLogging } from '../../util/api-helper.js'; | ||||
| import { fetchRemote } from '../../util/api-helper.js'; | ||||
| import { DWENGO_API_BASE } from '../../config.js'; | ||||
| import { LearningPathProvider } from './learning-path-provider.js'; | ||||
| import { getLogger, Logger } from '../../logging/initalize.js'; | ||||
|  | @ -42,7 +42,7 @@ const dwengoApiLearningPathProvider: LearningPathProvider = { | |||
|         const apiUrl = `${DWENGO_API_BASE}/learningPath/getPathsFromIdList`; | ||||
|         const params = { pathIdList: JSON.stringify({ hruids }), language }; | ||||
| 
 | ||||
|         const learningPaths = await fetchWithLogging<LearningPath[]>(apiUrl, `Learning paths for ${source}`, { params }); | ||||
|         const learningPaths = await fetchRemote<LearningPath[]>(apiUrl, `Learning paths for ${source}`, { params }); | ||||
| 
 | ||||
|         if (!learningPaths || learningPaths.length === 0) { | ||||
|             logger.warn(`⚠️ WARNING: No learning paths found for ${source}.`); | ||||
|  | @ -66,12 +66,11 @@ const dwengoApiLearningPathProvider: LearningPathProvider = { | |||
|         const apiUrl = `${DWENGO_API_BASE}/learningPath/search`; | ||||
|         const params = { all: query, language }; | ||||
| 
 | ||||
|         const searchResults = await fetchWithLogging<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, { params }); | ||||
|         const searchResults = await fetchRemote<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, { params }); | ||||
| 
 | ||||
|         if (searchResults) { | ||||
|             await Promise.all(searchResults?.map(async (it) => addProgressToLearningPath(it, personalizedFor))); | ||||
|         } | ||||
| 
 | ||||
|         return searchResults ?? []; | ||||
|     }, | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,28 +1,66 @@ | |||
| import axios, { AxiosRequestConfig } from 'axios'; | ||||
| import { getLogger, Logger } from '../logging/initalize.js'; | ||||
| import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js'; | ||||
| import { getCacheClient } from '../caching.js'; | ||||
| import { envVars, getEnvVar, getNumericEnvVar } from './envVars.js'; | ||||
| import { createHash } from 'crypto'; | ||||
| 
 | ||||
| const cacheClient = await getCacheClient(); | ||||
| const logger: Logger = getLogger(); | ||||
| const runMode: string = getEnvVar(envVars.RunMode); | ||||
| const prefix: string = getEnvVar(envVars.CacheKeyPrefix); | ||||
| 
 | ||||
| interface Options { | ||||
|     params?: Record<string, unknown> | LearningObjectIdentifier; | ||||
|     query?: Record<string, unknown>; | ||||
|     responseType?: 'json' | 'text'; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Utility function to fetch data from an API endpoint with error handling. | ||||
|  * Utility function to fetch data from an API endpoint with error handling and caching. | ||||
|  * Logs errors but does NOT throw exceptions to keep the system running. | ||||
|  * | ||||
|  * @param url The API endpoint to fetch from. | ||||
|  * @param description A short description of what is being fetched (for logging). | ||||
|  * @param options Contains further options such as params (the query params) and responseType (whether the response | ||||
|  *                should be parsed as JSON ("json") or whether it should be returned as plain text ("text") | ||||
|  * @param cacheTTL Time-to-live for the cache in seconds (default: 60 seconds). | ||||
|  * @returns The response data if successful, or null if an error occurs. | ||||
|  */ | ||||
| export async function fetchWithLogging<T>( | ||||
|     url: string, | ||||
|     description: string, | ||||
|     options?: { | ||||
|         params?: Record<string, unknown> | LearningObjectIdentifier; | ||||
|         query?: Record<string, unknown>; | ||||
|         responseType?: 'json' | 'text'; | ||||
| export async function fetchRemote<T>(url: string, description: string, options?: Options, cacheTTL?: number): Promise<T | null> { | ||||
|     if (runMode !== 'dev' && !runMode.includes('test') && cacheClient !== undefined) { | ||||
|         return fetchWithCache<T>(url, description, options, cacheTTL); | ||||
|     } | ||||
| ): Promise<T | null> { | ||||
| 
 | ||||
|     getLogger().debug(`🔄 INFO: Bypassing cache for ${description} at "${url}".`); | ||||
|     return fetchWithLogging(url, description, options); | ||||
| } | ||||
| 
 | ||||
| async function fetchWithCache<T>(url: string, description: string, options?: Options, cacheTTL?: number): Promise<T | null> { | ||||
|     // Combine the URL and parameters to create a unique cache key.
 | ||||
|     // NOTE Using a hash function to keep the key short, since Memcached has a limit on key size
 | ||||
|     const urlWithParams = `${url}${options?.params ? JSON.stringify(options.params) : ''}`; | ||||
|     const hashedUrl = createHash('sha256').update(urlWithParams).digest('hex'); | ||||
|     const key = `${prefix}:${hashedUrl}`; | ||||
| 
 | ||||
|     const cachedData = await cacheClient.get(key); | ||||
| 
 | ||||
|     if (cachedData?.value) { | ||||
|         logger.debug(`✅ INFO: Cache hit for ${description} at "${url}" (key: "${key}")`); | ||||
|         return JSON.parse(cachedData.value.toString()) as T; | ||||
|     } | ||||
| 
 | ||||
|     logger.debug(`🔄 INFO: Cache miss for ${description} at "${url}". Fetching data...`); | ||||
|     const response = await fetchWithLogging<T>(url, description, options); | ||||
| 
 | ||||
|     const ttl = cacheTTL || getNumericEnvVar(envVars.CacheTTL); | ||||
|     await cacheClient.set(key, JSON.stringify(response), { expires: ttl }); | ||||
|     logger.debug(`✅ INFO: Cached response for ${description} at "${url}" for ${ttl} seconds. (key: "${key}")`); | ||||
| 
 | ||||
|     return response; | ||||
| } | ||||
| 
 | ||||
| async function fetchWithLogging<T>(url: string, description: string, options?: Options): Promise<T | null> { | ||||
|     try { | ||||
|         const config: AxiosRequestConfig = options || {}; | ||||
|         const response = await axios.get<T>(url, config); | ||||
|  |  | |||
|  | @ -5,6 +5,7 @@ const STUDENT_IDP_PREFIX = IDP_PREFIX + 'STUDENT_'; | |||
| const TEACHER_IDP_PREFIX = IDP_PREFIX + 'TEACHER_'; | ||||
| const CORS_PREFIX = PREFIX + 'CORS_'; | ||||
| const LOGGING_PREFIX = PREFIX + 'LOGGING_'; | ||||
| const CACHE_PREFIX = PREFIX + 'CACHE_'; | ||||
| 
 | ||||
| interface EnvVar { | ||||
|     key: string; | ||||
|  | @ -39,6 +40,11 @@ export const envVars: Record<string, EnvVar> = { | |||
| 
 | ||||
|     LogLevel: { key: LOGGING_PREFIX + 'LEVEL', defaultValue: 'info' }, | ||||
|     LokiHost: { key: LOGGING_PREFIX + 'LOKI_HOST', defaultValue: 'http://localhost:3102' }, | ||||
| 
 | ||||
|     CacheHost: { key: CACHE_PREFIX + 'HOST' }, | ||||
|     CachePort: { key: CACHE_PREFIX + 'PORT', defaultValue: 11211 }, | ||||
|     CacheTTL: { key: CACHE_PREFIX + 'TTL', defaultValue: 60 * 60 * 24 }, // 24 hours
 | ||||
|     CacheKeyPrefix: { key: CACHE_PREFIX + 'KEY_PREFIX', defaultValue: 'dwengo' }, | ||||
| } as const; | ||||
| 
 | ||||
| /** | ||||
|  | @ -56,7 +62,7 @@ export function getEnvVar(envVar: EnvVar): string { | |||
|     } else if (envVar.required) { | ||||
|         throw new Error(`Missing environment variable: ${envVar.key}`); | ||||
|     } else { | ||||
|         return String(envVar.defaultValue) || ''; | ||||
|         return envVar.defaultValue !== undefined ? String(envVar.defaultValue) || '' : ''; | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  |  | |||
		Reference in a new issue
	
	 Joyelle Ndagijimana
						Joyelle Ndagijimana