From a1628b51f659d971d985b9254a3e3369f81102af Mon Sep 17 00:00:00 2001 From: Matt Lyons Date: Wed, 11 Dec 2024 13:06:45 -0600 Subject: [PATCH] Add OpenRPC documentation to JSON RPC methods (#1375) This does not cover providing documentation for all of the JSON RPC methods. It adds the framework for documentation and a fraction of the documentation itself to demonstrate. --- assets/localization/en.json | 1 + assets/localization/es.json | 1 + cspell.json | 1 + extensions/src/hello-someone/src/main.ts | 17 ++ .../src/platform-scripture-editor/src/main.ts | 36 +++ .../extension-host-check-runner.service.ts | 13 +- extensions/src/platform-scripture/src/main.ts | 108 ++++++++ lib/papi-dts/papi.d.ts | 235 ++++++++++++++++-- package-lock.json | 4 + package.json | 4 +- src/client/services/rpc-client.ts | 13 +- src/declarations/papi-shared-types.ts | 2 + src/extension-host/data/menu.data.json | 7 + src/main/main.ts | 111 +++++---- .../services/project-lookup.service-host.ts | 84 +++++++ src/main/services/rpc-server.ts | 32 +-- src/main/services/rpc-websocket-listener.ts | 138 +++++++++- .../components/web-view.component.tsx | 38 +++ src/renderer/services/dialog.service-host.ts | 135 +++++++++- src/shared/data/rpc.model.ts | 7 +- src/shared/models/openrpc.model.ts | 229 +++++++++++++++++ .../models/project-lookup.service-model.ts | 8 +- src/shared/models/rpc.interface.ts | 12 +- src/shared/services/command.service.ts | 3 + src/shared/services/dialog.service-model.ts | 2 +- .../services/extension-asset.service.ts | 27 +- src/shared/services/network-object.service.ts | 32 ++- src/shared/services/network.service.ts | 7 +- src/shared/services/papi-core.service.ts | 6 + .../project-settings.service-model.ts | 54 ++++ src/shared/services/settings.service-model.ts | 54 ++++ 31 files changed, 1305 insertions(+), 116 deletions(-) create mode 100644 src/shared/models/openrpc.model.ts diff --git a/assets/localization/en.json b/assets/localization/en.json index 295e8be60d..f184e48c56 100644 --- a/assets/localization/en.json +++ b/assets/localization/en.json @@ -29,6 +29,7 @@ "%mainMenu_about%": "About Platform.Bible", "%mainMenu_downloadInstallResources%": "Download/Install Resources", "%mainMenu_exit%": "Exit", + "%mainMenu_openDeveloperDocumentation%": "Open Developer Documentation", "%mainMenu_openProject%": "Open Project", "%mainMenu_settings%": "Settings", "%mainMenu_visitSupportBible%": "Visit Support.Bible", diff --git a/assets/localization/es.json b/assets/localization/es.json index 46bf7f5455..ba0bbf0746 100644 --- a/assets/localization/es.json +++ b/assets/localization/es.json @@ -141,6 +141,7 @@ "%mainMenu_exit%": "Salir", "%mainMenu_help%": "Ayuda", "%mainMenu_layout%": "Diseño", + "%mainMenu_openDeveloperDocumentation%": "Abrir documentación para desarrolladores", "%mainMenu_openProject%": "Abrir proyecto", "%mainMenu_project%": "Proyecto", "%mainMenu_settings%": "Configuración", 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..084d4b8628 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,173 @@ 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. + * + * Note that the types from https://www.npmjs.com/package/@open-rpc/meta-schema/v/1.14.2 are not + * very good. For example, all the properties of `Components` are of type `any` instead of the + * specific types they should be, and they redefine types for JSON Schema. So we're using our own + * types here instead. + */ + 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; + }; + export type MethodDocumentationWithoutName = Omit; + /** Documentation about a single method */ + export type SingleMethodDocumentation = { + method: MethodDocumentationWithoutName; + components?: Components; + }; + /** Documentation about all methods on a network object */ + export type NetworkObjectDocumentation = { + summary?: string; + description?: string; + methods?: Method[]; + components?: Components; + }; + /** Create an object of type {@link OpenRpc} to hold documentation for PAPI websocket methods */ + export function createEmptyOpenRpc(papiVersion: string): OpenRpc; + /** + * Get an empty {@link OpenRpc} method document object. Useful for populating documentation for + * methods that didn't have their own documentation provided. + */ + export function getEmptyMethodDocs(): MethodDocumentationWithoutName; +} declare module 'shared/models/rpc.interface' { import { ConnectionStatus, @@ -987,6 +1157,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 +1226,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 +1316,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 +1344,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 +1360,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 +1382,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 +1398,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 +1421,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 +1440,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 +1450,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 +1477,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 +1503,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 +1553,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 +1618,7 @@ declare module 'shared/services/network-object.service' { [property: string]: unknown; } | undefined, + objectDocumentation?: NetworkObjectDocumentation, ) => Promise>; export interface MinimalNetworkObjectService { get: typeof get; @@ -2899,6 +3094,8 @@ declare module 'papi-shared-types' { 'platform.quit': () => Promise; /** Restart the application */ 'platform.restart': () => Promise; + /** Open a browser to the platform's OpenRPC documentation */ + 'platform.openDeveloperDocumentationUrl': () => Promise; /** @deprecated 3 December 2024. Renamed to `platform.openSettings` */ 'platform.openProjectSettings': (webViewId: string) => Promise; /** @deprecated 3 December 2024. Renamed to `platform.openSettings` */ @@ -3413,6 +3610,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 +3626,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 +4178,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 +5263,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 @@ -6010,6 +6209,12 @@ declare module '@papi/core' { export type { default as IDataProviderEngine } from 'shared/models/data-provider-engine.model'; export type { DialogOptions } from 'shared/models/dialog-options.model'; export type { NetworkableObject, NetworkObject } from 'shared/models/network-object.model'; + export type { + Components as ComponentsDocumentation, + MethodDocumentationWithoutName, + NetworkObjectDocumentation, + SingleMethodDocumentation, + } from 'shared/models/openrpc.model'; export type { ExtensionDataScope, MandatoryProjectDataTypes, 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/declarations/papi-shared-types.ts b/src/declarations/papi-shared-types.ts index dff93ba0a4..1ffc8bee24 100644 --- a/src/declarations/papi-shared-types.ts +++ b/src/declarations/papi-shared-types.ts @@ -58,6 +58,8 @@ declare module 'papi-shared-types' { 'platform.quit': () => Promise; /** Restart the application */ 'platform.restart': () => Promise; + /** Open a browser to the platform's OpenRPC documentation */ + 'platform.openDeveloperDocumentationUrl': () => Promise; /** @deprecated 3 December 2024. Renamed to `platform.openSettings` */ 'platform.openProjectSettings': (webViewId: string) => Promise; /** @deprecated 3 December 2024. Renamed to `platform.openSettings` */ diff --git a/src/extension-host/data/menu.data.json b/src/extension-host/data/menu.data.json index 08d244dd6e..0ae3e949d2 100644 --- a/src/extension-host/data/menu.data.json +++ b/src/extension-host/data/menu.data.json @@ -53,6 +53,13 @@ "group": "platform.helpMisc", "order": 2, "command": "platform.about" + }, + { + "label": "%mainMenu_openDeveloperDocumentation%", + "localizeNotes": "Application main menu > Help > Open Developer Documentation", + "group": "platform.helpMisc", + "order": 3, + "command": "platform.openDeveloperDocumentationUrl" } ] }, diff --git a/src/main/main.ts b/src/main/main.ts index cbd351b0bd..be43d35dc5 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,34 @@ 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' }, + }, + }, + }, + ); + + const liveDocsUrl = + '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'; + commandService.registerCommand( + 'platform.openDeveloperDocumentationUrl', + async () => shell.openExternal(liveDocsUrl), + { + method: { + summary: 'Open the OpenRPC documentation in a browser', + params: [], + result: { + name: 'return value', + schema: { type: 'null' }, + }, + }, + }, + ); // #endregion @@ -360,8 +380,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..1a79d41668 100644 --- a/src/main/services/rpc-websocket-listener.ts +++ b/src/main/services/rpc-websocket-listener.ts @@ -1,19 +1,29 @@ +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, + getEmptyMethodDocs, + OpenRpc, + SingleMethodDocumentation, +} from '@shared/models/openrpc.model'; import RpcServer from './rpc-server'; /** @@ -34,7 +44,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 +61,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 +103,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 +130,119 @@ 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, + ...getEmptyMethodDocs(), + }); + } + }); + 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 +264,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..c6e963fcd3 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,130 @@ 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(); + const complexArrayDescription = + 'String representation of RegExp pattern(s) to match against projects’ projectInterfaces (using https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/test) to determine if they should be included. Each array entry is handled based on its type (at least one entry must match for this filter condition to pass). If the entry is a string, it will be matched against each projectInterface. If any match, the project will pass this filter condition. If the entry is an array of strings, each will be matched against each projectInterface. If every string matches against at least one projectInterface, the project will pass this filter condition. In other words, each entry in the first-level array is OR’ed together. Each entry in second-level arrays (arrays within the first-level array) are AND’ed together.'; // 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' }, + includeProjectInterfaces: { + type: 'array', + items: { + oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], + }, + description: complexArrayDescription, + }, + excludeProjectInterfaces: { + type: 'array', + items: { + oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], + }, + description: complexArrayDescription, + }, + includePdpFactoryIds: { type: 'array', items: { type: 'string' } }, + excludePdpFactoryIds: { type: 'array', items: { 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' }, + includeProjectInterfaces: { + type: 'array', + items: { + oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], + }, + description: complexArrayDescription, + }, + excludeProjectInterfaces: { + type: 'array', + items: { + oneOf: [{ type: 'string' }, { type: 'array', items: { type: 'string' } }], + }, + description: complexArrayDescription, + }, + includePdpFactoryIds: { type: 'array', items: { type: 'string' } }, + excludePdpFactoryIds: { type: 'array', items: { 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..0ebc54eb94 --- /dev/null +++ b/src/shared/models/openrpc.model.ts @@ -0,0 +1,229 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { WEBSOCKET_PORT } from '@shared/data/rpc.model'; +import type { JSONSchema7 } from 'json-schema'; + +// #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. + * + * Note that the types from https://www.npmjs.com/package/@open-rpc/meta-schema/v/1.14.2 are not + * very good. For example, all the properties of `Components` are of type `any` instead of the + * specific types they should be, and they redefine types for JSON Schema. So we're using our own + * types here instead. + */ +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 + +export type MethodDocumentationWithoutName = Omit; + +/** Documentation about a single method */ +export type SingleMethodDocumentation = { + method: MethodDocumentationWithoutName; + components?: Components; +}; + +/** Documentation about all methods on a network object */ +export type NetworkObjectDocumentation = { + summary?: string; + description?: string; + methods?: Method[]; + components?: Components; +}; + +/** Create an object of type {@link OpenRpc} to hold documentation for PAPI websocket methods */ +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: {}, + }; +} + +const emptyDocs: MethodDocumentationWithoutName = { + summary: '', + description: 'No documentation provided', + params: [], + result: { + name: 'return value', + schema: {}, + }, +}; +Object.freeze(emptyDocs); +Object.freeze(emptyDocs.params); +Object.freeze(emptyDocs.result); +// @ts-expect-error 2339 - TS doesn't understand that 'schema' is part of 'result' +Object.freeze(emptyDocs.result.schema); + +/** + * Get an empty {@link OpenRpc} method document object. Useful for populating documentation for + * methods that didn't have their own documentation provided. + */ +export function getEmptyMethodDocs(): MethodDocumentationWithoutName { + return emptyDocs; +} 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..41117af328 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 { getEmptyMethodDocs, 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,15 @@ const set = async ( netObjDetails.functionNames.forEach((functionName) => { const requestType = getNetworkObjectRequestType(id, functionName); - const unsub = networkService.registerRequestHandler(requestType, (...args: unknown[]) => + const methodDocs = + objectDocumentation.methods?.find((method) => method.name === functionName) ?? + getEmptyMethodDocs(); + 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: methodDocs }, ); 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/papi-core.service.ts b/src/shared/services/papi-core.service.ts index 69a74a7b32..d1c984c07d 100644 --- a/src/shared/services/papi-core.service.ts +++ b/src/shared/services/papi-core.service.ts @@ -26,6 +26,12 @@ export type { WithNotifyUpdate } from '@shared/models/data-provider-engine.model export type { default as IDataProviderEngine } from '@shared/models/data-provider-engine.model'; export type { DialogOptions } from '@shared/models/dialog-options.model'; export type { NetworkableObject, NetworkObject } from '@shared/models/network-object.model'; +export type { + Components as ComponentsDocumentation, + MethodDocumentationWithoutName, + NetworkObjectDocumentation, + SingleMethodDocumentation, +} from '@shared/models/openrpc.model'; export type { ExtensionDataScope, MandatoryProjectDataTypes, 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' }, + }, + }, + }, ); }, });