diff --git a/cspell.json b/cspell.json index 7f0eeeacb0..8605296522 100644 --- a/cspell.json +++ b/cspell.json @@ -60,6 +60,7 @@ "newtonsoft", "nodebuffer", "nums", + "openrpc", "papi", "papis", "paranext", diff --git a/extensions/src/hello-someone/src/main.ts b/extensions/src/hello-someone/src/main.ts index 21950762f6..1df6f9b8c8 100644 --- a/extensions/src/hello-someone/src/main.ts +++ b/extensions/src/hello-someone/src/main.ts @@ -301,6 +301,23 @@ export async function activate(context: ExecutionActivationContext): Promise { return `Hello ${name}!`; }, + { + method: { + summary: 'Say hello to someone', + params: [ + { + name: 'name', + required: true, + summary: 'Name of the person to say hello to', + schema: { type: 'string' }, + }, + ], + result: { + name: 'greeting', + schema: { type: 'string' }, + }, + }, + }, ); // Create a webview or get the existing webview if ours already exists diff --git a/extensions/src/platform-scripture-editor/src/main.ts b/extensions/src/platform-scripture-editor/src/main.ts index 223cf67756..6f002e773a 100644 --- a/extensions/src/platform-scripture-editor/src/main.ts +++ b/extensions/src/platform-scripture-editor/src/main.ts @@ -326,11 +326,47 @@ export async function activate(context: ExecutionActivationContext): Promise { ); unsubscribers.add(dataProvider.dispose); unsubscribers.add( - await papi.commands.registerCommand('platformScripture.registerCheck', registerCheck), + await papi.commands.registerCommand('platformScripture.registerCheck', registerCheck, { + method: { + summary: 'Register a new check to run on the platform', + description: + 'This will only run properly within the extension host. Do not call this from the websocket. Instead implement a check runner.', + params: [], + result: { + name: 'return value', + schema: {}, + }, + }, + }), ); resolve(); } catch (error) { diff --git a/extensions/src/platform-scripture/src/main.ts b/extensions/src/platform-scripture/src/main.ts index a3978dc866..5109a112c9 100644 --- a/extensions/src/platform-scripture/src/main.ts +++ b/extensions/src/platform-scripture/src/main.ts @@ -173,6 +173,24 @@ export async function activate(context: ExecutionActivationContext) { await papi.settings.set('platformScripture.includeMyParatext9Projects', newSettingValue); return newSettingValue; }, + { + method: { + summary: 'Toggle whether to include My Paratext 9 projects within the platform', + params: [ + { + name: 'shouldInclude', + required: false, + summary: 'Whether to include My Paratext 9 projects', + schema: { type: 'boolean' }, + }, + ], + result: { + name: 'return value', + summary: 'The new value of the setting', + schema: { type: 'boolean' }, + }, + }, + }, ); const includeProjectsValidatorPromise = papi.settings.registerValidator( 'platformScripture.includeMyParatext9Projects', @@ -197,6 +215,24 @@ export async function activate(context: ExecutionActivationContext) { const openCharactersInventoryPromise = papi.commands.registerCommand( 'platformScripture.openCharactersInventory', openPlatformCharactersInventory, + { + method: { + summary: 'Open the characters inventory', + params: [ + { + name: 'webViewId', + required: false, + summary: 'The ID of the web view tied to the project that the inventory is for', + schema: { type: 'string' }, + }, + ], + result: { + name: 'return value', + summary: 'The ID of the opened characters inventory web view', + schema: { type: 'string' }, + }, + }, + }, ); const characterInventoryWebViewProviderPromise = papi.webViewProviders.register( characterInventoryWebViewType, @@ -213,6 +249,24 @@ export async function activate(context: ExecutionActivationContext) { const openRepeatedWordsInventoryPromise = papi.commands.registerCommand( 'platformScripture.openRepeatedWordsInventory', openPlatformRepeatedWordsInventory, + { + method: { + summary: 'Open the repeated words inventory', + params: [ + { + name: 'webViewId', + required: false, + summary: 'The ID of the web view tied to the project that the inventory is for', + schema: { type: 'string' }, + }, + ], + result: { + name: 'return value', + summary: 'The ID of the opened repeated words inventory web view', + schema: { type: 'string' }, + }, + }, + }, ); const repeatableWordsInventoryWebViewProviderPromise = papi.webViewProviders.register( repeatedWordsInventoryWebViewType, @@ -229,6 +283,24 @@ export async function activate(context: ExecutionActivationContext) { const openMarkersInventoryPromise = papi.commands.registerCommand( 'platformScripture.openMarkersInventory', openPlatformMarkersInventory, + { + method: { + summary: 'Open the markers inventory', + params: [ + { + name: 'webViewId', + required: false, + summary: 'The ID of the web view tied to the project that the inventory is for', + schema: { type: 'string' }, + }, + ], + result: { + name: 'return value', + summary: 'The ID of the new open markers inventory web view', + schema: { type: 'string' }, + }, + }, + }, ); const markersInventoryWebViewProviderPromise = papi.webViewProviders.register( markersInventoryWebViewType, @@ -237,6 +309,24 @@ export async function activate(context: ExecutionActivationContext) { const configureChecksPromise = papi.commands.registerCommand( 'platformScripture.openConfigureChecks', configureChecks, + { + method: { + summary: 'Open the configure checks web view', + params: [ + { + name: 'webViewId', + required: false, + summary: 'The ID of the web view tied to the project that the checks are for', + schema: { type: 'string' }, + }, + ], + result: { + name: 'return value', + summary: 'The ID of the new configure checks web view', + schema: { type: 'string' }, + }, + }, + }, ); const configureChecksWebViewProviderPromise = papi.webViewProviders.register( configureChecksWebViewType, @@ -245,6 +335,24 @@ export async function activate(context: ExecutionActivationContext) { const showCheckResultsPromise = papi.commands.registerCommand( 'platformScripture.showCheckResults', showCheckResults, + { + method: { + summary: 'Show the check results', + params: [ + { + name: 'webViewId', + required: false, + summary: 'The ID of the web view tied to the project that the checks are for', + schema: { type: 'string' }, + }, + ], + result: { + name: 'return value', + summary: 'The ID of the new check results web view', + schema: { type: 'string' }, + }, + }, + }, ); const showCheckResultsWebViewProviderPromise = papi.webViewProviders.register( checkResultsListWebViewType, diff --git a/lib/papi-dts/papi.d.ts b/lib/papi-dts/papi.d.ts index 0ee5495723..1048c2bf8e 100644 --- a/lib/papi-dts/papi.d.ts +++ b/lib/papi-dts/papi.d.ts @@ -933,8 +933,11 @@ declare module 'shared/data/rpc.model' { * your request handler. */ export const UNREGISTER_METHOD = 'network:unregisterMethod'; - /** Get all methods that are currently registered on the network. */ - export const GET_METHODS = 'network:getMethods'; + /** + * Get all methods that are currently registered on the network. Required to be 'rpc.discover' by + * the OpenRPC specification. + */ + export const GET_METHODS = 'rpc.discover'; /** Prefix on requests that indicates that the request is a command */ export const CATEGORY_COMMAND = 'command'; } @@ -980,6 +983,161 @@ declare module 'shared/models/papi-network-event-emitter.model' { dispose: () => Promise; } } +declare module 'shared/models/openrpc.model' { + import type { JSONSchema7 } from 'json-schema'; + /** + * Describes APIs available to call using JSON-RPC 2.0 + * + * See https://github.com/open-rpc/meta-schema/releases - Release 1.14.2 aligns with OpenRPC 1.2.6. + * https://github.com/open-rpc/meta-schema/releases/download/1.14.2/open-rpc-meta-schema.json + * + * We don't want to go past 1.2.6 because https://playground.open-rpc.org/ doesn't support anything + * past 1.2.6 for now. See https://github.com/open-rpc/playground/issues/606. + */ + export type OpenRpc = { + openrpc: string; + info: Info; + servers?: Server[]; + methods: Method[]; + components?: Components; + externalDocs?: ExternalDocumentation; + }; + export type Components = { + schemas?: { + [key: string]: Schema; + }; + contentDescriptors?: { + [key: string]: ContentDescriptor; + }; + examples?: { + [key: string]: Example; + }; + links?: { + [key: string]: Link; + }; + errors?: { + [key: string]: Error; + }; + tags?: { + [key: string]: Tag; + }; + }; + export type ComponentsReference = `#/components/${string}`; + export type Contact = { + name?: string; + email?: string; + url?: string; + }; + export type ContentDescriptor = { + name: string; + schema: Schema; + required?: boolean; + summary?: string; + description?: string; + deprecated?: boolean; + }; + export type Error = { + code: number; + message: string; + data?: any; + }; + export type Example = { + name: string; + value: any; + summary?: string; + description?: string; + }; + export type ExamplePairingObject = { + name: string; + params: (Example | Reference)[]; + result: Example | Reference; + description?: string; + }; + export type ExternalDocumentation = { + url: string; + description?: string; + }; + export type Info = { + title: string; + version: string; + description?: string; + termsOfService?: string; + contact?: Contact; + license?: License; + }; + export type License = { + name: string; + url?: string; + }; + export type Link = { + name?: string; + summary?: string; + description?: string; + method?: string; + params?: { + [key: string]: any; + }; + server?: Server; + }; + export type Method = { + /** The canonical name for the method. The name MUST be unique within the methods array. */ + name: string; + params: (ContentDescriptor | Reference)[]; + result: ContentDescriptor | Reference; + /** A short summary of what the method does. */ + summary?: string; + /** + * A verbose explanation of the method behavior. GitHub Flavored Markdown syntax MAY be used for + * rich text representation. + */ + description?: string; + deprecated?: boolean; + servers?: Server[]; + tags?: (Tag | Reference)[]; + /** Format the server expects the params. Defaults to 'either'. */ + paramStructure?: 'by-name' | 'by-position' | 'either'; + errors?: (Error | Reference)[]; + links?: (Link | Reference)[]; + examples?: (ExamplePairingObject | Reference)[]; + externalDocs?: ExternalDocumentation; + }; + export type Reference = { + $ref: ComponentsReference; + }; + export type Server = { + url: string; + name?: string; + description?: string; + summary?: string; + variables?: { + [key: string]: ServerVariable; + }; + }; + export type ServerVariable = { + default: string; + description?: string; + enum?: string[]; + }; + export type Schema = JSONSchema7; + export type Tag = { + name: string; + description?: string; + externalDocs?: ExternalDocumentation; + }; + /** Documentation about a single method */ + export type SingleMethodDocumentation = { + method: Omit; + components?: Components; + }; + /** Documentation about all methods on a network object */ + export type NetworkObjectDocumentation = { + summary?: string; + description?: string; + methods?: Method[]; + components?: Components; + }; + export function createEmptyOpenRpc(papiVersion: string): OpenRpc; +} declare module 'shared/models/rpc.interface' { import { ConnectionStatus, @@ -987,6 +1145,7 @@ declare module 'shared/models/rpc.interface' { InternalRequestHandler, RequestParams, } from 'shared/data/rpc.model'; + import { SingleMethodDocumentation } from 'shared/models/openrpc.model'; import { SerializedRequestType } from 'shared/utils/util'; import { JSONRPCResponse } from 'json-rpc-2.0'; /** @@ -1055,10 +1214,18 @@ declare module 'shared/models/rpc.interface' { */ export interface IRpcMethodRegistrar extends IRpcHandler { /** Register a method that will be called if an RPC request is made */ - registerMethod: (methodName: string, method: InternalRequestHandler) => Promise; + registerMethod: ( + methodName: string, + method: InternalRequestHandler, + methodDocs?: SingleMethodDocumentation, + ) => Promise; /** Unregister a method so it is no longer available to RPC requests */ unregisterMethod: (methodName: string) => Promise; } + export type RegisteredRpcMethodDetails = { + handler: IRpcHandler; + methodDocs?: SingleMethodDocumentation; + }; } declare module 'client/services/web-socket.interface' { /** @@ -1137,6 +1304,7 @@ declare module 'client/services/rpc-client' { RequestParams, } from 'shared/data/rpc.model'; import { SerializedRequestType } from 'shared/utils/util'; + import { SingleMethodDocumentation } from 'shared/models/openrpc.model'; /** * Manages the JSON-RPC protocol on the client end of a websocket that connects to main * @@ -1164,7 +1332,11 @@ declare module 'client/services/rpc-client' { requestParams: RequestParams, ): Promise; emitEventOnNetwork(eventType: string, event: T): void; - registerMethod(methodName: string, method: InternalRequestHandler): Promise; + registerMethod( + methodName: string, + method: InternalRequestHandler, + methodDocs?: SingleMethodDocumentation, + ): Promise; unregisterMethod(methodName: string): Promise; private createNextRequestId; private addEventListenersToWebSocket; @@ -1176,9 +1348,10 @@ declare module 'client/services/rpc-client' { } declare module 'main/services/rpc-server' { import { JSONRPCResponse } from 'json-rpc-2.0'; - import { IRpcHandler } from 'shared/models/rpc.interface'; + import { IRpcHandler, RegisteredRpcMethodDetails } from 'shared/models/rpc.interface'; import { ConnectionStatus, RequestParams } from 'shared/data/rpc.model'; import { SerializedRequestType } from 'shared/utils/util'; + import { SingleMethodDocumentation } from 'shared/models/openrpc.model'; type PropagateEventMethod = (source: RpcServer, eventType: string, event: T) => void; /** * Manages the JSON-RPC protocol on the server end of a websocket owned by main. This class is not @@ -1197,14 +1370,14 @@ declare module 'main/services/rpc-server' { private readonly jsonRpcServer; /** Refers to any process that connected to main over the websocket */ private readonly jsonRpcClient; - private readonly rpcHandlerByMethodName; + private readonly rpcMethodDetailsByMethodName; /** Called by an RpcServer when all other RpcServers should emit an event over the network */ private readonly propagateEventMethod; constructor( name: string, webSocket: WebSocket, propagateEventMethod: PropagateEventMethod, - rpcHandlerByMethodName: Map, + rpcMethodDetailsByMethodName: Map, ); connect(): Promise; disconnect(): Promise; @@ -1213,7 +1386,7 @@ declare module 'main/services/rpc-server' { requestParams: RequestParams, ): Promise; emitEventOnNetwork(eventType: string, event: T): void; - registerRemoteMethod(methodName: string): boolean; + registerRemoteMethod(methodName: string, methodDocs?: SingleMethodDocumentation): boolean; unregisterRemoteMethod(methodName: string): boolean; private createNextRequestId; private addMethodToRpcServer; @@ -1236,6 +1409,7 @@ declare module 'main/services/rpc-websocket-listener' { import { IRpcMethodRegistrar } from 'shared/models/rpc.interface'; import { JSONRPCResponse } from 'json-rpc-2.0'; import { SerializedRequestType } from 'shared/utils/util'; + import { OpenRpc, SingleMethodDocumentation } from 'shared/models/openrpc.model'; /** * Owns the WebSocketServer that listens for clients to connect to the web socket. When a client * connects, an RpcServer is created in this same process to service that connection. @@ -1254,7 +1428,7 @@ declare module 'main/services/rpc-websocket-listener' { private nextSocketNumber; private readonly connectionMutex; private readonly rpcServerBySocket; - private readonly rpcHandlerByMethodName; + private readonly rpcMethodDetailsByMethodName; private readonly localMethodsByMethodName; constructor(); get nextSocketId(): string; @@ -1264,8 +1438,13 @@ declare module 'main/services/rpc-websocket-listener' { requestType: SerializedRequestType, requestParams: RequestParams, ): Promise; - registerMethod(methodName: string, method: InternalRequestHandler): Promise; + registerMethod( + methodName: string, + method: InternalRequestHandler, + methodDocs?: SingleMethodDocumentation, + ): Promise; unregisterMethod(methodName: string): Promise; + generateOpenRpcSchema(): OpenRpc; emitEventOnNetwork(eventType: string, event: T): void; private propagateEvent; private onClientConnect; @@ -1286,6 +1465,7 @@ declare module 'shared/services/network.service' { import { InternalRequestHandler } from 'shared/data/rpc.model'; import { UnsubscriberAsync, PlatformEventEmitter, PlatformEvent } from 'platform-bible-utils'; import { SerializedRequestType } from 'shared/utils/util'; + import { SingleMethodDocumentation } from 'shared/models/openrpc.model'; export function initialize(): Promise; /** Closes the network services gracefully */ export const shutdown: () => Promise; @@ -1311,6 +1491,7 @@ declare module 'shared/services/network.service' { export function registerRequestHandler( requestType: SerializedRequestType, requestHandler: InternalRequestHandler, + requestDocs?: SingleMethodDocumentation, ): Promise; /** * Creates a function that is a request function with a baked requestType. This is also nice because @@ -1360,6 +1541,7 @@ declare module 'shared/services/network-object.service' { LocalObjectToProxyCreator, NetworkObjectDetails, } from 'shared/models/network-object.model'; + import { NetworkObjectDocumentation } from 'shared/models/openrpc.model'; /** Sets up the service. Only runs once and always returns the same promise after that */ const initialize: () => Promise; /** @@ -1424,6 +1606,7 @@ declare module 'shared/services/network-object.service' { [property: string]: unknown; } | undefined, + objectDocumentation?: NetworkObjectDocumentation, ) => Promise>; export interface MinimalNetworkObjectService { get: typeof get; @@ -3413,6 +3596,7 @@ declare module 'papi-shared-types' { declare module 'shared/services/command.service' { import { UnsubscriberAsync } from 'platform-bible-utils'; import { CommandHandlers } from 'papi-shared-types'; + import { SingleMethodDocumentation } from 'shared/models/openrpc.model'; /** * Register a command on the papi to be handled here * @@ -3428,6 +3612,7 @@ declare module 'shared/services/command.service' { export const registerCommand: ( commandName: CommandName, handler: CommandHandlers[CommandName], + commandDocs?: SingleMethodDocumentation, ) => Promise; /** Send a command to the backend. */ export const sendCommand: ( @@ -3979,14 +4164,14 @@ declare module 'shared/models/project-lookup.service-model' { * * Note: If there are multiple PDPs available whose metadata matches the conditions provided by * the parameters, their project metadata will all be combined, so all available - * `projectInterface`s provided by the PDP Factory with the matching id (or all PDP Factories if - * no id is specified) for the project will be returned. If you need `projectInterface`s supported + * `projectInterface`s provided by the PDP Factory with the matching ID (or all PDP Factories if + * no ID is specified) for the project will be returned. If you need `projectInterface`s supported * by specific PDP Factories, you can access it at {@link ProjectMetadata.pdpFactoryInfo}. * * @param options Options for specifying filters for the project metadata retrieved. If a PDP - * Factory Id does not match the filter, it will not be contacted at all for this function call. + * Factory ID does not match the filter, it will not be contacted at all for this function call. * As a result, a PDP factory that intends to layer over other PDP factories **must** specify - * its id in `options.excludePdpFactoryIds` to avoid an infinite loop of calling this function. + * its ID in `options.excludePdpFactoryIds` to avoid an infinite loop of calling this function. * @returns ProjectMetadata for all projects stored on the local system */ getMetadataForAllProjects(options?: ProjectMetadataFilterOptions): Promise; @@ -5064,7 +5249,7 @@ declare module 'shared/services/dialog.service-model' { options?: DialogTypes[DialogTabType]['options'], ): Promise; /** - * Shows a select project dialog to the user and prompts the user to select a dialog + * Shows a select project dialog to the user and prompts the user to select a project * * @param options Various options for configuring the dialog that shows * @returns Returns the user's selected project id or `undefined` if the user cancels diff --git a/package-lock.json b/package-lock.json index 4c7f134c5c..4f6bc9cef7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -141,6 +141,10 @@ "webpack-dev-server": "^5.1.0", "webpack-merge": "^6.0.1", "yalc": "^1.0.0-pre.53" + }, + "engines": { + "node": ">=18.12.x", + "npm": ">=7.x" } }, "extensions": { diff --git a/package.json b/package.json index bebddf06d1..2bced98f86 100644 --- a/package.json +++ b/package.json @@ -234,7 +234,7 @@ "webpack-merge": "^6.0.1", "yalc": "^1.0.0-pre.53" }, - "devEngines": { + "engines": { "node": ">=18.12.x", "npm": ">=7.x" }, @@ -244,6 +244,6 @@ }, "workspaces": ["lib/*", "extensions", "extensions/src/*"], "volta": { - "node": "20.18.0" + "node": "22.12.0" } } diff --git a/src/client/services/rpc-client.ts b/src/client/services/rpc-client.ts index d68eb0d8fc..cfb8bd19ca 100644 --- a/src/client/services/rpc-client.ts +++ b/src/client/services/rpc-client.ts @@ -25,6 +25,7 @@ import { import { createWebSocket } from '@client/services/web-socket.factory'; import { AsyncVariable, Mutex, MutexMap } from 'platform-bible-utils'; import { bindClassMethods, SerializedRequestType } from '@shared/utils/util'; +import { SingleMethodDocumentation } from '@shared/models/openrpc.model'; /** * Manages the JSON-RPC protocol on the client end of a websocket that connects to main @@ -147,15 +148,19 @@ export default class RpcClient implements IRpcMethodRegistrar { this.jsonRpcClient.notify(eventType, [event]); } - async registerMethod(methodName: string, method: InternalRequestHandler): Promise { + async registerMethod( + methodName: string, + method: InternalRequestHandler, + methodDocs?: SingleMethodDocumentation, + ): Promise { if (this.jsonRpcServer.hasMethod(methodName)) return false; const mutex = this.registrationMutexMap.get(methodName); return mutex.runExclusive(async () => { if (this.jsonRpcServer.hasMethod(methodName)) return false; - const successful = await this.jsonRpcClient.request(REGISTER_METHOD, [methodName]); - if (successful) + const success = await this.jsonRpcClient.request(REGISTER_METHOD, [methodName, methodDocs]); + if (success) this.jsonRpcServer.addMethod(methodName, (params: RequestParams) => method(...params)); - return successful; + return success; }); } diff --git a/src/main/main.ts b/src/main/main.ts index cbd351b0bd..c7ee0a3791 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -6,7 +6,7 @@ * using webpack. This gives us some performance wins. */ import path from 'path'; -import { app, BrowserWindow, shell, ipcMain, IpcMainInvokeEvent } from 'electron'; +import { app, BrowserWindow, shell, ipcMain } from 'electron'; // Removed until we have a release. See https://github.com/paranext/paranext-core/issues/83 /* import { autoUpdater } from 'electron-updater'; */ import windowStateKeeper from 'electron-window-state'; @@ -22,13 +22,13 @@ import extensionAssetProtocolService from '@main/services/extension-asset-protoc import { wait, serialize } from 'platform-bible-utils'; import { CommandNames } from 'papi-shared-types'; import { SerializedRequestType } from '@shared/utils/util'; -import networkObjectStatusService from '@shared/services/network-object-status.service'; import { get } from '@shared/services/project-data-provider.service'; import { VerseRef } from '@sillsdev/scripture'; import { startNetworkObjectStatusService } from '@main/services/network-object-status.service-host'; import { DEV_MODE_RENDERER_INDICATOR } from '@shared/data/platform.data'; import { startProjectLookupService } from '@main/services/project-lookup.service-host'; import { PROJECT_INTERFACE_PLATFORM_BASE } from '@shared/models/project-data-provider.model'; +import { GET_METHODS } from '@shared/data/rpc.model'; const PROCESS_CLOSE_TIME_OUT = 2000; /** @@ -205,25 +205,14 @@ async function main() { } }); - /** Map from ipc channel to handler function. Use with ipcRenderer.invoke */ - const ipcHandlers: { - [ipcChannel: SerializedRequestType]: ( - event: IpcMainInvokeEvent, - // We don't know the exact parameter types since ipc handlers can be anything - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...args: any[] - ) => Promise | unknown; - } = { - 'electronAPI:env.test': (_event, message: string) => `From main.ts: test ${message}`, - }; - app .whenReady() // eslint-disable-next-line promise/always-return .then(() => { // Set up ipc handlers - Object.entries(ipcHandlers).forEach(([ipcChannel, ipcHandler]) => - ipcMain.handle(ipcChannel, ipcHandler), + ipcMain.handle( + 'electronAPI:env.test', + (_event, message: string) => `From main.ts: test ${message}`, ); createWindow(); @@ -237,33 +226,43 @@ async function main() { }) .catch(logger.info); - Object.entries(ipcHandlers).forEach(([ipcHandle, handler]) => { - networkService.registerRequestHandler( - // Re-assert type after passing through `forEach`. - // eslint-disable-next-line no-type-assertion/no-type-assertion - ipcHandle as SerializedRequestType, - // Handle with an empty event. - // eslint-disable-next-line no-type-assertion/no-type-assertion - async (...args: unknown[]) => handler({} as IpcMainInvokeEvent, ...args), - ); - }); - // #endregion // #region Register commands - // `main.ts`'s command handler declarations are in `papi-shared-types.ts` so they can be picked up - // by papi-dts - // This map should allow any functions because commands can be any function type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const commandHandlers: { [commandName: string]: (...args: any[]) => any } = { - 'platform.restartExtensionHost': async () => { - restartExtensionHost(); + // `main.ts`'s command handler declarations are in `papi-shared-types.ts` so papi-dts sees them + + commandService.registerCommand('platform.restartExtensionHost', restartExtensionHost, { + method: { + summary: 'Restart the extension host which reloads and reinitializes TS/JS extensions', + params: [], + result: { + name: 'return value', + schema: { type: 'null' }, + }, }, - 'platform.quit': async () => { + }); + + commandService.registerCommand( + 'platform.quit', + async () => { app.quit(); }, - 'platform.restart': async () => { + { + method: { + summary: 'Close the platform, including all processes started by it', + params: [], + result: { + name: 'return value', + schema: { type: 'null' }, + }, + }, + }, + ); + + commandService.registerCommand( + 'platform.restart', + async () => { // Only set up to restart once. This could accidentally be called twice if `app.quit` is // canceled or if someone requested to restart multiple times in the few seconds it takes // `app.quit` to run because of the `will-quit` event @@ -278,13 +277,17 @@ async function main() { } app.quit(); }, - }; - - Object.entries(commandHandlers).forEach(([commandName, handler]) => { - // Re-assert type after passing through `forEach`. - // eslint-disable-next-line no-type-assertion/no-type-assertion - commandService.registerCommand(commandName as CommandNames, handler); - }); + { + method: { + summary: 'Restart the platform, including all processes started by it', + params: [], + result: { + name: 'return value', + schema: { type: 'null' }, + }, + }, + }, + ); // #endregion @@ -360,8 +363,9 @@ async function main() { // Dump all the network objects after things have settled a bit setTimeout(async () => { logger.info( - `Available network objects after 30 seconds: ${serialize( - await networkObjectStatusService.getAllNetworkObjectDetails(), + `Available network request types after 30 seconds: ${serialize( + // eslint-disable-next-line no-type-assertion/no-type-assertion + await networkService.request(GET_METHODS as SerializedRequestType, {}), )}`, ); }, 30000); diff --git a/src/main/services/project-lookup.service-host.ts b/src/main/services/project-lookup.service-host.ts index 2cb5735277..8682228483 100644 --- a/src/main/services/project-lookup.service-host.ts +++ b/src/main/services/project-lookup.service-host.ts @@ -22,5 +22,89 @@ export async function startProjectLookupService(): Promise { await networkObjectService.set( NETWORK_OBJECT_NAME_PROJECT_LOOKUP_SERVICE, projectLookupService, + undefined, + undefined, + { + summary: 'Provides metadata for projects known by the platform', + methods: [ + { + name: 'getMetadataForAllProjects', + summary: 'Provide metadata for all projects that have PDP factories', + description: + 'Note: If there are multiple PDPs available whose metadata matches the conditions provided by the parameters, their project metadata will all be combined, so all available `projectInterface`s provided by the PDP Factory with the matching ID (or all PDP Factories if no ID is specified) for the project will be returned.', + params: [ + { + name: 'options', + required: false, + summary: + 'Options for specifying filters for the project metadata retrieved. If a PDP Factory ID does not match the filter, it will not be contacted at all for this function call. As a result, a PDP factory that intends to layer over other PDP factories **must** specify its ID in `options.excludePdpFactoryIds` to avoid an infinite loop of calling this function.', + schema: { + $ref: '#/components/schemas/ProjectMetadataFilterOptions', + }, + }, + ], + result: { + name: 'return value', + schema: { + type: 'array', + items: { + $ref: '#/components/schemas/ProjectMetadata', + }, + }, + }, + }, + ], + components: { + schemas: { + ProjectMetadata: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'ID of the project (must be unique and case insensitive)', + }, + projectInterfaces: { + type: 'array', + description: + 'All `projectInterface`s (aka standardized sets of methods on a PDP) that Project Data Providers for this project support. Indicates what sort of project data should be available on this project.', + items: { type: 'string' }, + }, + pdpFactoryInfo: { + type: 'object', + description: 'Information about the PDP Factories associated with the project.', + }, + }, + }, + ProjectMetadataFilterOptions: { + type: 'object', + properties: { + includePdpFactoryIds: { + type: 'array', + items: { + type: 'string', + }, + description: 'List of PDP Factory Ids to include in the metadata retrieval.', + }, + excludePdpFactoryIds: { + type: 'array', + items: { + type: 'string', + }, + description: 'List of PDP Factory Ids to exclude from the metadata retrieval.', + }, + projectIds: { + type: 'array', + items: { + type: 'string', + }, + description: 'List of project Ids to filter the metadata retrieval.', + }, + }, + additionalProperties: false, + description: 'Options for specifying filters for the project metadata retrieved.', + }, + }, + }, + }, ); } diff --git a/src/main/services/rpc-server.ts b/src/main/services/rpc-server.ts index 7ee8e7853c..3ba02da9eb 100644 --- a/src/main/services/rpc-server.ts +++ b/src/main/services/rpc-server.ts @@ -10,7 +10,7 @@ import { JSONRPCServer, } from 'json-rpc-2.0'; import logger from '@shared/services/logger.service'; -import { IRpcHandler } from '@shared/models/rpc.interface'; +import { IRpcHandler, RegisteredRpcMethodDetails } from '@shared/models/rpc.interface'; import { ConnectionStatus, createErrorResponse, @@ -25,6 +25,7 @@ import { UNREGISTER_METHOD, } from '@shared/data/rpc.model'; import { bindClassMethods, SerializedRequestType } from '@shared/utils/util'; +import { SingleMethodDocumentation } from '@shared/models/openrpc.model'; type PropagateEventMethod = (source: RpcServer, eventType: string, event: T) => void; @@ -45,7 +46,7 @@ export default class RpcServer implements IRpcHandler { private readonly jsonRpcServer: JSONRPCServer; /** Refers to any process that connected to main over the websocket */ private readonly jsonRpcClient: JSONRPCClient; - private readonly rpcHandlerByMethodName: Map; + private readonly rpcMethodDetailsByMethodName: Map; /** Called by an RpcServer when all other RpcServers should emit an event over the network */ private readonly propagateEventMethod: PropagateEventMethod; @@ -53,7 +54,7 @@ export default class RpcServer implements IRpcHandler { name: string, webSocket: WebSocket, propagateEventMethod: PropagateEventMethod, - rpcHandlerByMethodName: Map, + rpcMethodDetailsByMethodName: Map, ) { bindClassMethods.call(this); this.name = name; @@ -74,7 +75,7 @@ export default class RpcServer implements IRpcHandler { (payload) => sendPayloadToWebSocket(this.ws, payload), this.createNextRequestId, ); - this.rpcHandlerByMethodName = rpcHandlerByMethodName; + this.rpcMethodDetailsByMethodName = rpcMethodDetailsByMethodName; this.addMethodToRpcServer(REGISTER_METHOD, this.registerRemoteMethod); this.addMethodToRpcServer(UNREGISTER_METHOD, this.unregisterRemoteMethod); @@ -110,14 +111,15 @@ export default class RpcServer implements IRpcHandler { const isLocal = this.jsonRpcServer.hasMethod(requestType); if (isLocal) response = await this.jsonRpcServer.receive(requestToSend); else { - const handler = this.rpcHandlerByMethodName.get(requestType); - if (handler === this) response = await this.jsonRpcClient.requestAdvanced(requestToSend); - else if (!handler) + const methodDetails = this.rpcMethodDetailsByMethodName.get(requestType); + if (!methodDetails) return createErrorResponse( `'${requestType}' not found`, JSONRPCErrorCode.MethodNotFound, requestId, ); + const { handler } = methodDetails; + if (handler === this) response = await this.jsonRpcClient.requestAdvanced(requestToSend); else return handler.request(requestType, requestParams); } if (response) return response; @@ -137,17 +139,17 @@ export default class RpcServer implements IRpcHandler { this.jsonRpcClient.notify(eventType, [event]); } - registerRemoteMethod(methodName: string): boolean { - if (this.rpcHandlerByMethodName.has(methodName)) return false; - this.rpcHandlerByMethodName.set(methodName, this); + registerRemoteMethod(methodName: string, methodDocs?: SingleMethodDocumentation): boolean { + if (this.rpcMethodDetailsByMethodName.has(methodName)) return false; + this.rpcMethodDetailsByMethodName.set(methodName, { handler: this, methodDocs }); return true; } unregisterRemoteMethod(methodName: string): boolean { // Don't allow one client to tell us to unregister a method from a different client - const registeredHandler = this.rpcHandlerByMethodName.get(methodName); - const handlersMatch = registeredHandler === this; - if (handlersMatch) this.rpcHandlerByMethodName.delete(methodName); + const methodDetails = this.rpcMethodDetailsByMethodName.get(methodName); + const handlersMatch = !!methodDetails && methodDetails.handler === this; + if (handlersMatch) this.rpcMethodDetailsByMethodName.delete(methodName); return handlersMatch; } @@ -188,11 +190,11 @@ export default class RpcServer implements IRpcHandler { this.jsonRpcClient.rejectAllPendingRequests(`Web socket ${this.name} has closed`); this.removeEventListenersFromWebSocket(); this.connectionStatus = ConnectionStatus.Disconnected; - this.rpcHandlerByMethodName.forEach((handler, methodName) => { + this.rpcMethodDetailsByMethodName.forEach(({ handler }, methodName) => { if (handler !== this) return; logger.info(`Method '${methodName}' removed since websocket ${this.name} closed`); - this.rpcHandlerByMethodName.delete(methodName); + this.rpcMethodDetailsByMethodName.delete(methodName); }); } diff --git a/src/main/services/rpc-websocket-listener.ts b/src/main/services/rpc-websocket-listener.ts index d61fa079f9..398a2f2cf7 100644 --- a/src/main/services/rpc-websocket-listener.ts +++ b/src/main/services/rpc-websocket-listener.ts @@ -1,19 +1,28 @@ +import { app } from 'electron'; import { ConnectionStatus, createErrorResponse, createSuccessResponse, EventHandler, + GET_METHODS, InternalRequestHandler, + REGISTER_METHOD, RequestParams, requestWithRetry, + UNREGISTER_METHOD, WEBSOCKET_PORT, } from '@shared/data/rpc.model'; -import { IRpcMethodRegistrar, IRpcHandler } from '@shared/models/rpc.interface'; +import { IRpcMethodRegistrar, RegisteredRpcMethodDetails } from '@shared/models/rpc.interface'; import { Mutex } from 'platform-bible-utils'; import { WebSocketServer } from 'ws'; import logger from '@shared/services/logger.service'; import { JSONRPCErrorCode, JSONRPCResponse } from 'json-rpc-2.0'; import { bindClassMethods, SerializedRequestType } from '@shared/utils/util'; +import { + createEmptyOpenRpc, + OpenRpc, + SingleMethodDocumentation, +} from '@shared/models/openrpc.model'; import RpcServer from './rpc-server'; /** @@ -34,7 +43,7 @@ export default class RpcWebSocketListener implements IRpcMethodRegistrar { private nextSocketNumber = 1; private readonly connectionMutex = new Mutex(); private readonly rpcServerBySocket = new Map(); - private readonly rpcHandlerByMethodName = new Map(); + private readonly rpcMethodDetailsByMethodName = new Map(); private readonly localMethodsByMethodName = new Map(); constructor() { @@ -51,6 +60,18 @@ export default class RpcWebSocketListener implements IRpcMethodRegistrar { return this.connectionMutex.runExclusive(() => { if (this.connectionStatus !== ConnectionStatus.Disconnected) return false; this.localEventHandler = localEventHandler; + this.registerMethod(GET_METHODS, this.generateOpenRpcSchema, { + method: { + summary: 'Get documentation for all available methods on the PAPI websocket', + params: [], + result: { + name: 'return value', + schema: { + type: 'object', + }, + }, + }, + }); this.webSocketServer = new WebSocketServer({ port: WEBSOCKET_PORT }); this.webSocketServer.addListener('connection', this.onClientConnect); @@ -81,12 +102,13 @@ export default class RpcWebSocketListener implements IRpcMethodRegistrar { ): Promise { return requestWithRetry( async () => { - const handler = this.rpcHandlerByMethodName.get(requestType); - if (!handler) + const methodDetails = this.rpcMethodDetailsByMethodName.get(requestType); + if (!methodDetails) return createErrorResponse( `No handler found for ${requestType}`, JSONRPCErrorCode.MethodNotFound, ); + const { handler } = methodDetails; if (handler !== this) return handler.request(requestType, requestParams); const method = this.localMethodsByMethodName.get(requestType); if (!method) @@ -107,26 +129,125 @@ export default class RpcWebSocketListener implements IRpcMethodRegistrar { ); } - async registerMethod(methodName: string, method: InternalRequestHandler): Promise { + async registerMethod( + methodName: string, + method: InternalRequestHandler, + methodDocs?: SingleMethodDocumentation, + ): Promise { if ( - this.rpcHandlerByMethodName.has(methodName) || + this.rpcMethodDetailsByMethodName.has(methodName) || this.localMethodsByMethodName.has(methodName) ) return false; - this.rpcHandlerByMethodName.set(methodName, this); + this.rpcMethodDetailsByMethodName.set(methodName, { handler: this, methodDocs }); this.localMethodsByMethodName.set(methodName, method); return true; } async unregisterMethod(methodName: string): Promise { - const handler = this.rpcHandlerByMethodName.get(methodName); - if (handler !== this) return false; - this.rpcHandlerByMethodName.delete(methodName); + const methodDetails = this.rpcMethodDetailsByMethodName.get(methodName); + if (!methodDetails || methodDetails.handler !== this) return false; + this.rpcMethodDetailsByMethodName.delete(methodName); this.localMethodsByMethodName.delete(methodName); return true; } + generateOpenRpcSchema(): OpenRpc { + const openRpcSchema = createEmptyOpenRpc(app.getVersion()); + openRpcSchema.methods = [ + { + name: REGISTER_METHOD, + summary: 'Register a method on the network', + params: [ + { + name: 'methodName', + required: true, + summary: 'Name of the method to register', + schema: { type: 'string' }, + }, + { + name: 'methodDocs', + required: false, + summary: 'Documentation for the method in OpenRPC format', + schema: { type: 'object' }, + }, + ], + result: { + name: 'return value', + summary: 'Whether the method was successfully registered', + schema: { type: 'boolean' }, + }, + }, + { + name: UNREGISTER_METHOD, + summary: 'Unregister a method on the network', + params: [ + { + name: 'methodName', + required: true, + summary: 'Name of the method to unregister', + schema: { type: 'string' }, + }, + ], + result: { + name: 'return value', + summary: 'Whether the method was successfully unregistered', + schema: { type: 'boolean' }, + }, + }, + ]; + this.rpcMethodDetailsByMethodName.forEach((details, methodName) => { + if (details.methodDocs) { + const newDocs = { name: methodName, ...details.methodDocs.method }; + // Overwrite the name with `methodName` in case `details.methodDocs.method` included a name + newDocs.name = methodName; + openRpcSchema.methods.push(newDocs); + if (details.methodDocs.components) { + openRpcSchema.components = { + schemas: { + ...details.methodDocs.components.schemas, + ...openRpcSchema.components?.schemas, + }, + contentDescriptors: { + ...details.methodDocs.components.contentDescriptors, + ...openRpcSchema.components?.contentDescriptors, + }, + examples: { + ...details.methodDocs.components.examples, + ...openRpcSchema.components?.examples, + }, + links: { + ...details.methodDocs.components.links, + ...openRpcSchema.components?.links, + }, + errors: { + ...details.methodDocs.components.errors, + ...openRpcSchema.components?.errors, + }, + tags: { + ...details.methodDocs.components.tags, + ...openRpcSchema.components?.tags, + }, + }; + } + } else { + openRpcSchema.methods.push({ + name: methodName, + summary: '', + description: 'No documentation provided', + params: [], + result: { + name: 'return value', + schema: {}, + }, + }); + } + }); + openRpcSchema.methods.sort((a, b) => a.name.localeCompare(b.name)); + return openRpcSchema; + } + emitEventOnNetwork(eventType: string, event: T): void { this.rpcServerBySocket.forEach((rpcServer) => { rpcServer.emitEventOnNetwork(eventType, event); @@ -148,7 +269,7 @@ export default class RpcWebSocketListener implements IRpcMethodRegistrar { this.nextSocketId, webSocket, this.propagateEvent, - this.rpcHandlerByMethodName, + this.rpcMethodDetailsByMethodName, ); rpcServer.connect(); this.rpcServerBySocket.set(webSocket, rpcServer); diff --git a/src/renderer/components/web-view.component.tsx b/src/renderer/components/web-view.component.tsx index 6eda73b9dd..e0e613beb3 100644 --- a/src/renderer/components/web-view.component.tsx +++ b/src/renderer/components/web-view.component.tsx @@ -75,6 +75,44 @@ export default function WebView({ return registerRequestHandler( getWebViewMessageRequestType(id), (...args: Parameters) => callback(args), + { + method: { + summary: `Post a message to a WebView with id "${id}". Expected to be used only by the Web View Provider that created the web view or the Web View Controller that represents the web view created by the Web View Provider.`, + params: [ + { + name: 'webViewNonce', + required: true, + summary: 'A nonce to ensure that the message is coming from the correct source', + schema: { + type: 'string', + }, + }, + { + name: 'message', + required: true, + summary: 'The message to send to the WebView', + schema: { + type: 'string', + }, + }, + { + name: 'targetOrigin', + required: false, + summary: + 'Expected origin of the web view. Does not send the message if the web view origin does not match. See https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage#targetorigin for more information. Defaults to same origin only (works automatically with React and HTML web views)', + schema: { + type: 'string', + }, + }, + ], + result: { + name: 'return value', + schema: { + type: 'null', + }, + }, + }, + }, ); }, [id], diff --git a/src/renderer/services/dialog.service-host.ts b/src/renderer/services/dialog.service-host.ts index bc16a68900..d8340cf351 100644 --- a/src/renderer/services/dialog.service-host.ts +++ b/src/renderer/services/dialog.service-host.ts @@ -2,7 +2,7 @@ import { DIALOG_OPTIONS_LOCALIZABLE_PROPERTY_KEYS, DialogData, } from '@shared/models/dialog-options.model'; -import { CATEGORY_DIALOG, DialogService } from '@shared/services/dialog.service-model'; +import { CATEGORY_DIALOG } from '@shared/services/dialog.service-model'; import * as networkService from '@shared/services/network.service'; import { aggregateUnsubscriberAsyncs, @@ -10,12 +10,17 @@ import { serialize, newGuid, LocalizeKey, + UnsubscriberAsync, } from 'platform-bible-utils'; import * as webViewService from '@renderer/services/web-view.service-host'; import { serializeRequestType } from '@shared/utils/util'; import logger from '@shared/services/logger.service'; import SELECT_PROJECT_DIALOG from '@renderer/components/dialogs/select-project.dialog'; -import { DialogTabTypes, DialogTypes } from '@renderer/components/dialogs/dialog-definition.model'; +import { + DialogTabTypes, + type DialogTypes, +} from '@renderer/components/dialogs/dialog-definition.model'; +import * as DialogTypesValues from '@renderer/components/dialogs/dialog-definition.model'; import { hookUpDialogService } from '@renderer/components/dialogs/dialog-base.data'; import localizationService from '@shared/services/localization.service'; @@ -240,18 +245,96 @@ async function selectProject( return showDialog(SELECT_PROJECT_DIALOG.tabType, options); } -const dialogService: DialogService = { - showDialog, - selectProject, -}; - /** Register the commands that back the PAPI dialog service */ export async function startDialogService(): Promise { await initialize(); // register functions as requests - const unsubPromises = Object.entries(dialogService).map(([fnName, handler]) => - networkService.registerRequestHandler(serializeRequestType(CATEGORY_DIALOG, fnName), handler), + const unsubPromises: Promise[] = []; + unsubPromises.push( + networkService.registerRequestHandler( + serializeRequestType(CATEGORY_DIALOG, 'showDialog'), + showDialog, + { + method: { + summary: 'Shows a dialog to the user and prompts the user to respond', + params: [ + { + name: 'dialogType', + required: true, + summary: 'The type of dialog to show the user', + schema: { + enum: Object.values(DialogTypesValues), + }, + }, + { + name: 'options', + required: false, + summary: 'Various options for configuring the dialog that shows', + schema: { + type: 'object', + properties: { + title: { type: 'string' }, + iconUrl: { type: 'string' }, + prompt: { type: 'string' }, + includeProjectIds: { type: 'array', items: { type: 'string' } }, + excludeProjectIds: { type: 'array', items: { type: 'string' } }, + selectedProjectIds: { type: 'array', items: { type: 'string' } }, + selectedBookIds: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + ], + result: { + name: 'return value', + summary: 'Response from user', + schema: { + oneOf: [ + { type: 'string' }, + { type: 'array', items: { type: 'string' } }, + { type: 'null' }, + ], + }, + }, + }, + }, + ), + ); + unsubPromises.push( + networkService.registerRequestHandler( + serializeRequestType(CATEGORY_DIALOG, 'selectProject'), + selectProject, + { + method: { + summary: + 'Shows a select project dialog to the user and prompts the user to select a project', + params: [ + { + name: 'options', + summary: 'Various options for configuring the dialog that shows', + required: false, + schema: { + type: 'object', + properties: { + title: { type: 'string' }, + iconUrl: { type: 'string' }, + prompt: { type: 'string' }, + includeProjectIds: { type: 'array', items: { type: 'string' } }, + excludeProjectIds: { type: 'array', items: { type: 'string' } }, + }, + }, + }, + ], + result: { + name: 'return value', + summary: "The user's selected project id or nothing if the user cancels", + schema: { + oneOf: [{ type: 'string' }, { type: 'null' }], + }, + }, + }, + }, + ), ); // Wait to successfully register all requests diff --git a/src/shared/data/rpc.model.ts b/src/shared/data/rpc.model.ts index 7b254a1878..91a7776ee1 100644 --- a/src/shared/data/rpc.model.ts +++ b/src/shared/data/rpc.model.ts @@ -212,8 +212,11 @@ export const REGISTER_METHOD = 'network:registerMethod'; */ export const UNREGISTER_METHOD = 'network:unregisterMethod'; -/** Get all methods that are currently registered on the network. */ -export const GET_METHODS = 'network:getMethods'; +/** + * Get all methods that are currently registered on the network. Required to be 'rpc.discover' by + * the OpenRPC specification. + */ +export const GET_METHODS = 'rpc.discover'; /** Prefix on requests that indicates that the request is a command */ export const CATEGORY_COMMAND = 'command'; diff --git a/src/shared/models/openrpc.model.ts b/src/shared/models/openrpc.model.ts new file mode 100644 index 0000000000..34d6dbd6a7 --- /dev/null +++ b/src/shared/models/openrpc.model.ts @@ -0,0 +1,201 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { WEBSOCKET_PORT } from '@shared/data/rpc.model'; +import type { JSONSchema7 } from 'json-schema'; + +// TODO +// https://playground.open-rpc.org/?transport=websocket&schemaUrl=ws%3A%2F%2Flocalhost%3A8876%0A&uiSchema[appBar][ui:splitView]=false&uiSchema[appBar][ui:input]=false&uiSchema[appBar][ui:examplesDropdown]=false&uiSchema[appBar][ui:transports]=false&uiSchema[appBar][ui:darkMode]=true&uiSchema[appBar][ui:title]=PAPI + +// #region OpenRPC types translated from JSON Schema to TypeScript + +/** + * Describes APIs available to call using JSON-RPC 2.0 + * + * See https://github.com/open-rpc/meta-schema/releases - Release 1.14.2 aligns with OpenRPC 1.2.6. + * https://github.com/open-rpc/meta-schema/releases/download/1.14.2/open-rpc-meta-schema.json + * + * We don't want to go past 1.2.6 because https://playground.open-rpc.org/ doesn't support anything + * past 1.2.6 for now. See https://github.com/open-rpc/playground/issues/606. + */ +export type OpenRpc = { + openrpc: string; + info: Info; + servers?: Server[]; + methods: Method[]; + components?: Components; + externalDocs?: ExternalDocumentation; +}; + +export type Components = { + schemas?: { [key: string]: Schema }; + contentDescriptors?: { [key: string]: ContentDescriptor }; + examples?: { [key: string]: Example }; + links?: { [key: string]: Link }; + errors?: { [key: string]: Error }; + tags?: { [key: string]: Tag }; +}; + +export type ComponentsReference = `#/components/${string}`; + +export type Contact = { + name?: string; + email?: string; + url?: string; +}; + +export type ContentDescriptor = { + name: string; + schema: Schema; + required?: boolean; + summary?: string; + description?: string; + deprecated?: boolean; +}; + +export type Error = { + code: number; + message: string; + data?: any; +}; + +export type Example = { + name: string; + value: any; + summary?: string; + description?: string; +}; + +export type ExamplePairingObject = { + name: string; + params: (Example | Reference)[]; + result: Example | Reference; + description?: string; +}; + +export type ExternalDocumentation = { + url: string; + description?: string; +}; + +export type Info = { + title: string; + version: string; + description?: string; + termsOfService?: string; + contact?: Contact; + license?: License; +}; + +export type License = { + name: string; + url?: string; +}; + +export type Link = { + name?: string; + summary?: string; + description?: string; + method?: string; + params?: { [key: string]: any }; + server?: Server; +}; + +export type Method = { + /** The canonical name for the method. The name MUST be unique within the methods array. */ + name: string; + params: (ContentDescriptor | Reference)[]; + result: ContentDescriptor | Reference; + /** A short summary of what the method does. */ + summary?: string; + /** + * A verbose explanation of the method behavior. GitHub Flavored Markdown syntax MAY be used for + * rich text representation. + */ + description?: string; + deprecated?: boolean; + servers?: Server[]; + tags?: (Tag | Reference)[]; + /** Format the server expects the params. Defaults to 'either'. */ + paramStructure?: 'by-name' | 'by-position' | 'either'; + errors?: (Error | Reference)[]; + links?: (Link | Reference)[]; + examples?: (ExamplePairingObject | Reference)[]; + externalDocs?: ExternalDocumentation; +}; + +export type Reference = { + $ref: ComponentsReference; +}; + +export type Server = { + url: string; + name?: string; + description?: string; + summary?: string; + variables?: { [key: string]: ServerVariable }; +}; + +export type ServerVariable = { + default: string; + description?: string; + enum?: string[]; +}; + +export type Schema = JSONSchema7; + +export type Tag = { + name: string; + description?: string; + externalDocs?: ExternalDocumentation; +}; + +// #endregion + +/** Documentation about a single method */ +export type SingleMethodDocumentation = { + method: Omit; + components?: Components; +}; + +/** Documentation about all methods on a network object */ +export type NetworkObjectDocumentation = { + summary?: string; + description?: string; + methods?: Method[]; + components?: Components; +}; + +export function createEmptyOpenRpc(papiVersion: string): OpenRpc { + return { + openrpc: '1.2.6', + info: { + version: papiVersion, + title: 'Live PAPI documentation', + description: + 'All methods currently registered with PAPI. They change dynamically as methods are registered and unregistered. This page does not automatically refresh to show any changes.', + contact: { + name: 'Platform.Bible Team', + url: 'https://discord.gg/tzw22PUEAY', + }, + license: { + name: 'MIT', + url: 'https://github.com/paranext/paranext-core/blob/main/LICENSE', + }, + }, + servers: [ + { + name: 'Platform.Bible and Paratext 10 Studio Wiki', + url: 'https://github.com/paranext/paranext-core/wiki/Platform.Bible-and-Paratext-10-Studio', + }, + { + name: 'TypeScript docs for PAPI React components, general library components, and API calls', + url: 'https://paranext.github.io/paranext-core/', + }, + { + name: 'PAPI websocket', + url: `ws://localhost:${WEBSOCKET_PORT}`, + }, + ], + methods: [], + components: {}, + }; +} diff --git a/src/shared/models/project-lookup.service-model.ts b/src/shared/models/project-lookup.service-model.ts index 084ff3ed29..7955819c5d 100644 --- a/src/shared/models/project-lookup.service-model.ts +++ b/src/shared/models/project-lookup.service-model.ts @@ -75,14 +75,14 @@ export type ProjectLookupServiceType = { * * Note: If there are multiple PDPs available whose metadata matches the conditions provided by * the parameters, their project metadata will all be combined, so all available - * `projectInterface`s provided by the PDP Factory with the matching id (or all PDP Factories if - * no id is specified) for the project will be returned. If you need `projectInterface`s supported + * `projectInterface`s provided by the PDP Factory with the matching ID (or all PDP Factories if + * no ID is specified) for the project will be returned. If you need `projectInterface`s supported * by specific PDP Factories, you can access it at {@link ProjectMetadata.pdpFactoryInfo}. * * @param options Options for specifying filters for the project metadata retrieved. If a PDP - * Factory Id does not match the filter, it will not be contacted at all for this function call. + * Factory ID does not match the filter, it will not be contacted at all for this function call. * As a result, a PDP factory that intends to layer over other PDP factories **must** specify - * its id in `options.excludePdpFactoryIds` to avoid an infinite loop of calling this function. + * its ID in `options.excludePdpFactoryIds` to avoid an infinite loop of calling this function. * @returns ProjectMetadata for all projects stored on the local system */ getMetadataForAllProjects(options?: ProjectMetadataFilterOptions): Promise; diff --git a/src/shared/models/rpc.interface.ts b/src/shared/models/rpc.interface.ts index ae962e2b80..6a19e334dd 100644 --- a/src/shared/models/rpc.interface.ts +++ b/src/shared/models/rpc.interface.ts @@ -4,6 +4,7 @@ import { InternalRequestHandler, RequestParams, } from '@shared/data/rpc.model'; +import { SingleMethodDocumentation } from '@shared/models/openrpc.model'; import { SerializedRequestType } from '@shared/utils/util'; import { JSONRPCResponse } from 'json-rpc-2.0'; @@ -74,7 +75,16 @@ export interface IRpcHandler { */ export interface IRpcMethodRegistrar extends IRpcHandler { /** Register a method that will be called if an RPC request is made */ - registerMethod: (methodName: string, method: InternalRequestHandler) => Promise; + registerMethod: ( + methodName: string, + method: InternalRequestHandler, + methodDocs?: SingleMethodDocumentation, + ) => Promise; /** Unregister a method so it is no longer available to RPC requests */ unregisterMethod: (methodName: string) => Promise; } + +export type RegisteredRpcMethodDetails = { + handler: IRpcHandler; + methodDocs?: SingleMethodDocumentation; +}; diff --git a/src/shared/services/command.service.ts b/src/shared/services/command.service.ts index 4669ab3d85..14bf4fb487 100644 --- a/src/shared/services/command.service.ts +++ b/src/shared/services/command.service.ts @@ -8,6 +8,7 @@ import { UnsubscriberAsync } from 'platform-bible-utils'; import { serializeRequestType } from '@shared/utils/util'; import { CommandHandlers, CommandNames } from 'papi-shared-types'; import { CATEGORY_COMMAND } from '@shared/data/rpc.model'; +import { SingleMethodDocumentation } from '@shared/models/openrpc.model'; /** * Register a command on the papi to be handled here @@ -24,10 +25,12 @@ import { CATEGORY_COMMAND } from '@shared/data/rpc.model'; export const registerCommand = ( commandName: CommandName, handler: CommandHandlers[CommandName], + commandDocs?: SingleMethodDocumentation, ): Promise => { return networkService.registerRequestHandler( serializeRequestType(CATEGORY_COMMAND, commandName), handler, + commandDocs, ); }; diff --git a/src/shared/services/dialog.service-model.ts b/src/shared/services/dialog.service-model.ts index 434b302bee..c540a710d3 100644 --- a/src/shared/services/dialog.service-model.ts +++ b/src/shared/services/dialog.service-model.ts @@ -23,7 +23,7 @@ export interface DialogService { options?: DialogTypes[DialogTabType]['options'], ): Promise; /** - * Shows a select project dialog to the user and prompts the user to select a dialog + * Shows a select project dialog to the user and prompts the user to select a project * * @param options Various options for configuring the dialog that shows * @returns Returns the user's selected project id or `undefined` if the user cancels diff --git a/src/shared/services/extension-asset.service.ts b/src/shared/services/extension-asset.service.ts index e829dd1bfd..b882cf37e4 100644 --- a/src/shared/services/extension-asset.service.ts +++ b/src/shared/services/extension-asset.service.ts @@ -48,11 +48,36 @@ const initialize = async () => { initializePromise = (async (): Promise => { if (isInitialized) return; getAsset = (await import('@extension-host/services/asset-retrieval.service')).default; + const requestType = serializeRequestType(CATEGORY_EXTENSION_ASSET, GET_EXTENSION_ASSET_REQUEST); await networkService.registerRequestHandler( - serializeRequestType(CATEGORY_EXTENSION_ASSET, GET_EXTENSION_ASSET_REQUEST), + requestType, async (extensionName: string, assetName: string) => { return getExtensionAsset(extensionName, assetName); }, + { + method: { + summary: 'Get an asset from an extension', + params: [ + { + name: 'extensionName', + required: true, + summary: 'Name of the extension to get the asset from', + schema: { type: 'string' }, + }, + { + name: 'assetName', + required: true, + summary: 'Name of the asset to get', + schema: { type: 'string' }, + }, + ], + result: { + name: 'return value', + summary: 'Base64 encoded asset if it exists', + schema: { oneOf: [{ type: 'string' }, { type: 'null' }] }, + }, + }, + }, ); isInitialized = true; diff --git a/src/shared/services/network-object.service.ts b/src/shared/services/network-object.service.ts index 178e009eca..057584a141 100644 --- a/src/shared/services/network-object.service.ts +++ b/src/shared/services/network-object.service.ts @@ -23,6 +23,7 @@ import { NetworkObjectDetails, } from '@shared/models/network-object.model'; import logger from '@shared/services/logger.service'; +import { NetworkObjectDocumentation } from '@shared/models/openrpc.model'; // #endregion @@ -419,6 +420,7 @@ const set = async ( objectToShare: T, objectType: string = 'object', objectAttributes: { [property: string]: unknown } | undefined = undefined, + objectDocumentation: NetworkObjectDocumentation = {}, ): Promise> => { await initialize(); @@ -430,8 +432,25 @@ const set = async ( // Check if there is a network object with this ID remotely by trying to register it const unsubPromises = [ - networkService.registerRequestHandler(getNetworkObjectRequestType(id), () => - Promise.resolve(true), + networkService.registerRequestHandler( + getNetworkObjectRequestType(id), + () => Promise.resolve(true), + { + method: { + summary: objectDocumentation.summary ?? '', + description: objectDocumentation.description ?? '', + params: [], + result: { + name: 'return value', + summary: 'Does the network object exist?', + required: true, + schema: { + type: 'boolean', + }, + }, + }, + components: objectDocumentation.components, + }, ), ]; @@ -447,10 +466,21 @@ const set = async ( netObjDetails.functionNames.forEach((functionName) => { const requestType = getNetworkObjectRequestType(id, functionName); - const unsub = networkService.registerRequestHandler(requestType, (...args: unknown[]) => + const docs = objectDocumentation.methods?.find((method) => method.name === functionName) ?? { + summary: '', + description: 'No documentation provided', + params: [], + result: { + name: 'return value', + schema: {}, + }, + }; + const unsub = networkService.registerRequestHandler( + requestType, // Assert as any to allow indexing on the function name // eslint-disable-next-line @typescript-eslint/no-explicit-any, no-type-assertion/no-type-assertion - Promise.resolve((objectToShare as any)[functionName](...args)), + (...args: unknown[]) => Promise.resolve((objectToShare as any)[functionName](...args)), + { method: docs }, ); unsubPromises.push(unsub); }); diff --git a/src/shared/services/network.service.ts b/src/shared/services/network.service.ts index eb1f62515b..fb55d286a6 100644 --- a/src/shared/services/network.service.ts +++ b/src/shared/services/network.service.ts @@ -9,6 +9,7 @@ import { CATEGORY_COMMAND, InternalRequestHandler, fixupResponse, + GET_METHODS, } from '@shared/data/rpc.model'; import { stringLength, @@ -23,6 +24,7 @@ import PapiNetworkEventEmitter from '@shared/models/papi-network-event-emitter.m import { IRpcMethodRegistrar } from '@shared/models/rpc.interface'; import { createRpcHandler } from '@shared/services/rpc-handler.factory'; import logger from '@shared/services/logger.service'; +import { SingleMethodDocumentation } from '@shared/models/openrpc.model'; // #region Local event handling @@ -112,6 +114,8 @@ function validateCommandFormatting(commandName: string) { /** Check to make sure the request follows any request registration rules */ function validateRequestTypeFormatting(requestType: SerializedRequestType) { + // This request type doesn't conform to the normal format but is required by OpenRPC + if (requestType.toString() === GET_METHODS) return; const { category, directive } = deserializeRequestType(requestType); if (category === CATEGORY_COMMAND) { validateCommandFormatting(directive); @@ -153,10 +157,11 @@ export const request = async , TReturn>( export async function registerRequestHandler( requestType: SerializedRequestType, requestHandler: InternalRequestHandler, + requestDocs?: SingleMethodDocumentation, ): Promise { await initialize(); if (!jsonRpc) throw new Error('RPC handler not set'); - const success = await jsonRpc.registerMethod(requestType, requestHandler); + const success = await jsonRpc.registerMethod(requestType, requestHandler, requestDocs); if (!success) throw new Error(`Could not register request handler for ${requestType}`); return async () => { if (!jsonRpc) return false; diff --git a/src/shared/services/project-settings.service-model.ts b/src/shared/services/project-settings.service-model.ts index b3fa45ada2..1e94e7200b 100644 --- a/src/shared/services/project-settings.service-model.ts +++ b/src/shared/services/project-settings.service-model.ts @@ -17,6 +17,60 @@ export const projectSettingsServiceObjectToProxy = Object.freeze({ return networkService.registerRequestHandler( serializeRequestType(CATEGORY_EXTENSION_PROJECT_SETTING_VALIDATOR, key), validator, + { + method: { + summary: `Validate whether a given value is allowed for project setting "${key}"`, + params: [ + { + name: 'newValue', + required: true, + summary: 'The new value to validate', + schema: { + oneOf: [ + { type: 'object' }, + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { type: 'array' }, + ], + }, + }, + { + name: 'currentValue', + required: true, + summary: 'The current value of the setting', + schema: { + oneOf: [ + { type: 'object' }, + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { type: 'array' }, + ], + }, + }, + { + name: 'allChanges', + required: true, + summary: 'All changes to the settings', + schema: { + oneOf: [ + { type: 'object' }, + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { type: 'array' }, + ], + }, + }, + ], + result: { + name: 'return value', + summary: 'Whether the new setting value is valid', + schema: { type: 'boolean' }, + }, + }, + }, ); }, }); diff --git a/src/shared/services/settings.service-model.ts b/src/shared/services/settings.service-model.ts index c1d9e60cae..9fa2973046 100644 --- a/src/shared/services/settings.service-model.ts +++ b/src/shared/services/settings.service-model.ts @@ -39,6 +39,60 @@ export const settingsServiceObjectToProxy = Object.freeze({ return networkService.registerRequestHandler( serializeRequestType(CATEGORY_EXTENSION_SETTING_VALIDATOR, key), validator, + { + method: { + summary: `Validate whether a given value is allowed for setting "${key}"`, + params: [ + { + name: 'newValue', + required: true, + summary: 'The new value to validate', + schema: { + oneOf: [ + { type: 'object' }, + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { type: 'array' }, + ], + }, + }, + { + name: 'currentValue', + required: true, + summary: 'The current value of the setting', + schema: { + oneOf: [ + { type: 'object' }, + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { type: 'array' }, + ], + }, + }, + { + name: 'allChanges', + required: true, + summary: 'All changes to the settings', + schema: { + oneOf: [ + { type: 'object' }, + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { type: 'array' }, + ], + }, + }, + ], + result: { + name: 'return value', + summary: 'Whether the new setting value is valid', + schema: { type: 'boolean' }, + }, + }, + }, ); }, });