From 54d953cffff97c4a821f216102ad612f92fd9fbc Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Thu, 23 Apr 2026 16:06:56 +0100 Subject: [PATCH] Merge pull request #32743 from overleaf/ar-new-v1-api [web] new v1 api client GitOrigin-RevId: 7ba0deef0d10526198a2a6ba997c2dcff7b7e5a5 --- services/web/app/src/Features/V1/V1Api.mjs | 9 +++++ .../web/app/src/infrastructure/Modules.d.ts | 27 +++++++++++++ .../web/app/src/infrastructure/Modules.mjs | 40 +++++++++++++++---- 3 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 services/web/app/src/infrastructure/Modules.d.ts diff --git a/services/web/app/src/Features/V1/V1Api.mjs b/services/web/app/src/Features/V1/V1Api.mjs index be8e4ef988..3d0b762b55 100644 --- a/services/web/app/src/Features/V1/V1Api.mjs +++ b/services/web/app/src/Features/V1/V1Api.mjs @@ -4,6 +4,7 @@ import settings from '@overleaf/settings' import Errors from '../Errors/Errors.js' import { promisifyMultiResult } from '@overleaf/promise-utils' import OError from '@overleaf/o-error' +import Modules from '../../infrastructure/Modules.mjs' // TODO: check what happens when these settings aren't defined const DEFAULT_V1_PARAMS = { @@ -16,6 +17,14 @@ const DEFAULT_V1_PARAMS = { timeout: settings.apis.v1.timeout, } +export async function makeV1Request(options) { + const results = await Modules.promises.hooks.fire('makeV1Request', options) + if (!Array.isArray(results) || results.length === 0) { + throw new Error('No module provides a makeV1Request implementation') + } + return results[0] +} + const v1Request = request.defaults(DEFAULT_V1_PARAMS) function makeRequest(options, callback) { diff --git a/services/web/app/src/infrastructure/Modules.d.ts b/services/web/app/src/infrastructure/Modules.d.ts new file mode 100644 index 0000000000..ac30592bdd --- /dev/null +++ b/services/web/app/src/infrastructure/Modules.d.ts @@ -0,0 +1,27 @@ +import type { RequestInit, Response } from 'node-fetch' + +export type V1RequestOptions = Omit & { + uri: string + qs?: Record + expectJson?: boolean + expectedStatusCodes?: number[] + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' +} + +export interface KnownHookSignatures { + makeV1Request: ( + options: V1RequestOptions + ) => Promise<{ body: unknown; response: Response }> +} + +export type HookName = keyof KnownHookSignatures | string + +export type HookParameters = + K extends keyof KnownHookSignatures + ? Parameters + : any[] + +export type HookReturnType = + K extends keyof KnownHookSignatures + ? Awaited> + : any diff --git a/services/web/app/src/infrastructure/Modules.mjs b/services/web/app/src/infrastructure/Modules.mjs index e6b0e251d2..b5f6b0f27d 100644 --- a/services/web/app/src/infrastructure/Modules.mjs +++ b/services/web/app/src/infrastructure/Modules.mjs @@ -12,12 +12,16 @@ import Metrics from '@overleaf/metrics' /** @import { WebModule } from "../../../types/web-module" */ /** @import { RequestHandler } from "express" */ +/** + * @import { HookName, HookParameters, HookReturnType } from './Modules' + */ + const MODULE_BASE_PATH = Path.join(import.meta.dirname, '/../../../modules') /** @type {WebModule[]} */ const _modules = [] let _modulesLoaded = false -/** @type {Record} */ +/** @type {Partial>} */ const _hooks = {} /** @type {Record} */ @@ -46,9 +50,20 @@ async function loadModulesImpl() { await import(settingsCheckModule) } for (const moduleName of Settings.moduleImportSequence || []) { - const module = await import( - Path.join(MODULE_BASE_PATH, moduleName, 'index.mjs') + const typescriptModule = Path.join( + MODULE_BASE_PATH, + moduleName, + 'index.mts' ) + let module + if (fs.existsSync(typescriptModule)) { + module = await import(typescriptModule) + } else { + module = await import( + Path.join(MODULE_BASE_PATH, moduleName, 'index.mjs') + ) + } + /** @type {WebModule & {name: string}} */ const loadedModule = module.default || module @@ -194,8 +209,9 @@ async function attachHooks() { } /** - * @param {any} name - * @param {any} method + * @template {HookName} K + * @param {K} name + * @param {(...args: HookParameters) => HookReturnType| Promise>} method */ function attachHook(name, method) { if (_hooks[name] == null) { @@ -221,8 +237,10 @@ async function attachMiddleware() { } /** - * @param {any} name - * @param {...any} args + * @template {HookName} K + * @param {K} name + * @param {HookParameters} args + * @returns {Promise[]>} */ async function fireHook(name, ...args) { // ensure that modules are loaded if we need to fire a hook @@ -250,6 +268,10 @@ async function getMiddleware(name) { return _middleware[name] || [] } +/** + * @typedef {(name: K, ...args: [...HookParameters, (err: any, results?: HookReturnType[]) => void]) => void} CallbackFireHook + */ + export default { applyNonCsrfRouter, applyRouter, @@ -261,7 +283,9 @@ export default { start, hooks: { attach: attachHook, - fire: callbackify(fireHook), + fire: /** @type {CallbackFireHook} */ ( + /** @type {unknown} */ (callbackify(/** @type {any} */ (fireHook))) + ), }, middleware: getMiddleware, promises: {