diff --git a/CHANGELOG.md b/CHANGELOG.md index d475fe259..8efa6a891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). * Added [**FDC3 Workbench**](https://fdc3.finos.org/toolbox/fdc3-workbench/), an FDC3 API developer application ([#457](https://github.com/finos/FDC3/pull/457)) * Added advice on how to `broadcast` complex context types, composed of other types, so that other apps can listen for both the complex type and simpler constituent types ([#464](https://github.com/finos/FDC3/pull/464)) * Added the ability to return data from an intent, via the addition of an IntentHandler type and a `getResult()` to IntentResolution, both of which return a Promise of a Context object. ([#495](https://github.com/finos/FDC3/pull/495)) +* Added a field to specify the Context type that intent can return to the AppD Application schema and extended the findIntent API calls to be able to use it for resolution. ([#499](https://github.com/finos/FDC3/pull/499)) +* Added the ability to return a Channel from an intent (via the `IntentResult` type), resolver support for intents that return Channels and the concept of PrivateChannels. ([#508](https://github.com/finos/FDC3/pull/508)) * Added error `UserCancelled` to the `ResolveError` enumeration to be used when user closes the resolver UI or otherwise cancels resolution of a raised intent ([#522 ](https://github.com/finos/FDC3/pull/522)) * Added an `instanceId` (and optional `instanceMetadata`) field to `AppMetadata` allowing it to refer to specific app instances and thereby supporting targetting of intents to specific app instances. Also added a `findInstanes()` function to the desktop agent. ([#509]((https://github.com/finos/FDC3/pull/509)) * Added a References and Bibliography section to the Standard's documentation to hold links to 'normative references' and other documentation that is useful for understanding the standard ([#530](https://github.com/finos/FDC3/pull/530)) diff --git a/docs/api/ref/Channel.md b/docs/api/ref/Channel.md index 18938b5a8..a2ec6c79f 100644 --- a/docs/api/ref/Channel.md +++ b/docs/api/ref/Channel.md @@ -54,7 +54,7 @@ Uniquely identifies the channel. It is either assigned by the desktop agent (sys public readonly type: string; ``` -Can be _system_ or _app_. +Can be _system_, _app_ or _private_. ### `displayMetadata` diff --git a/docs/api/ref/DesktopAgent.md b/docs/api/ref/DesktopAgent.md index dee8c246a..e3c8ad2fe 100644 --- a/docs/api/ref/DesktopAgent.md +++ b/docs/api/ref/DesktopAgent.md @@ -27,14 +27,15 @@ interface DesktopAgent { addContextListener(handler: ContextHandler): Promise; // intents - findIntent(intent: string, context?: Context): Promise; - findIntentsByContext(context: Context): Promise>; + findIntent(intent: string, context?: Context, resultType?: string): Promise; + findIntentsByContext(context: Context, resultType?: string): Promise>; raiseIntent(intent: string, context: Context, app?: TargetApp): Promise; raiseIntentForContext(context: Context, app?: TargetApp): Promise; addIntentListener(intent: string, handler: IntentHandler): Promise; // channels getOrCreateChannel(channelId: string): Promise; + createPrivateChannel(): Promise; getSystemChannels(): Promise>; // optional channel management functions joinChannel(channelId: string) : Promise; @@ -84,7 +85,12 @@ const contactListener = await fdc3.addContextListener('fdc3.contact', contact => ```ts addIntentListener(intent: string, handler: IntentHandler): Promise; ``` - Adds a listener for incoming Intents from the Agent. The handler function may return void or a promise that should resolve to a context object representing any data that should be returned to the app that raised the intent. If an error is thrown by the handler function, the promise returned is rejected, or a promise is not returned then the Desktop Agent MUST reject the promise returned by the `getResult()` function of the `IntentResolution`. + +Adds a listener for incoming intents from the Desktop Agent. The handler function may return void or a promise that resolves to a [`IntentResult`](Types#intentresult), which is either a [`Context`](Types#context) object, representing any data that should be returned to the app that raised the intent, or a [`Channel`](Channel) or [`PrivateChannel`](PrivateChannel) over which data responses will be sent. The `IntentResult` will be returned to app that raised the intent via the [`IntentResolution`](Metadata#intentresolution) and retrieved from it using the `getResult()` function. + +The Desktop Agent MUST reject the promise returned by the `getResult()` function of `IntentResolution` if: (1) the intent handling function's returned promise rejects, (2) the intent handling function doesn't return a promise, or (3) the returned promise resolves to an invalid type. + +The [`PrivateChannel`](PrivateChannel) type is provided to support synchronisation of data transmitted over returned channels, by allowing both parties to listen for events denoting subscription and unsubscription from the returned channel. `PrivateChannels` are only retrievable via raising an intent. #### Examples @@ -99,12 +105,34 @@ const listener = fdc3.addIntentListener('StartChat', context => { fdc3.addIntentListener("CreateOrder", (context) => { return new Promise((resolve) => { // go create the order - resolve({type: "fdc3.order", id: { "orderId": 1234 }}); + resolve({type: "fdc3.order", id: { "orderId": 1234}}); + }); +}); + +//Handle a raised intent and return a Private Channel over which response will be sent +fdc3.addIntentListener("QuoteStream", async (context) => { + const channel: PrivateChannel = await fdc3.createPrivateChannel(); + const symbol = context.id.symbol; + +// Called when the remote side adds a context listener + const addContextListener = channel.onAddContextListener((contextType) => { + // broadcast price quotes as they come in from our quote feed + feed.onQuote(symbol, (price) => { + channel.broadcast({ type: "price", price}); + }); + }); + + // Stop the feed if the remote side closes + const disconnectListener = channel.onDisconnect(() => { + feed.stop(symbol); }); + + return channel; }); ``` #### See also +* [`PrivateChannel`](PrivateChannel) * [`Listener`](Types#listener) * [`Context`](Types#context) * [`IntentHandler`](Types#intenthandler) @@ -162,20 +190,22 @@ let resolution = fdc3.raiseIntent("ViewInstrument", context, instances[0]); ### `findIntent` ```ts -findIntent(intent: string, context?: Context): Promise; +findIntent(intent: string, context?: Context, resultType?: string): Promise; ``` -Find out more information about a particular intent by passing its name, and optionally its context. +Find out more information about a particular intent by passing its name, and optionally its context and/or a desired result context type. `findIntent` is effectively granting programmatic access to the Desktop Agent's resolver. It returns a promise resolving to the intent, its metadata and metadata about the apps and app instances that are registered to handle it. This can be used to raise the intent against a specific app or app instance. -If the resolution fails, the promise will return an `Error` with a string from the [`ResolveError`](ResolveError) enumeration. +If the resolution fails, the promise will return an `Error` with a string from the [`ResolveError`](Errors#resolveerror) enumeration. +Result types may be a type name, the string `"channel"` (which indicates that the app will return a channel) or a string indicating a channel that returns a specific type, e.g. `"channel"`. If intent resolution to an app returning a channel is requested, the desktop agent MUST include both apps that are registered as returning a channel and those registered as returning a channel with a specific type in the response. #### Examples +I know 'StartChat' exists as a concept, and want to know which apps can resolve it: + ```js -// I know 'StartChat' exists as a concept, and want to know more about it ... const appIntent = await fdc3.findIntent("StartChat"); // returns a single AppIntent: // { @@ -204,56 +234,100 @@ const appIntent = await fdc3.findIntent("StartChat"); // ] ``` +An optional input context object and/or `resultType` argument may be specified, which the resolver MUST use to filter the returned applications such that each supports the specified input and result types. +```js +const appIntent = await fdc3.findIntent("StartChat", context); + +// returns only apps that support the type of the specified input context: +// { +// intent: { name: "StartChat", displayName: "Chat" }, +// apps: { name: "Symphony" }] +// } + +const appIntent = await fdc3.findIntent("ViewContact", "fdc3.ContactList"); +// returns only apps that return the specified result type: +// { +// intent: { name: "ViewContact", displayName: "View Contact Details" }, +// apps: { name: "MyCRM", resultType: "fdc3.ContactList"}] +// } + +const appIntent = await fdc3.findIntent("QuoteStream", instrument, "channel"); +// returns only apps that return a channel which will receive the specified input and result types: +// { +// intent: { name: "QuoteStream", displayName: "Quotes stream" }, +// apps: { name: "MyOMS", resultType: "channel"}] +// } +``` + #### See also * [`ResolveError`](Errors#resolveerror) ### `findIntentsByContext` ```ts -findIntentsByContext(context: Context): Promise>; +findIntentsByContext(context: Context, resultType?: string): Promise>; ``` -Find all the available intents for a particular context. +Find all the available intents for a particular context, and optionally a desired result context type. + `findIntentsByContext` is effectively granting programmatic access to the Desktop Agent's resolver. A promise resolving to all the intents, their metadata and metadata about the apps and app instances that registered as handlers is returned, based on the context types the intents have registered. If the resolution fails, the promise will return an `Error` with a string from the [`ResolveError`](Errors#resolveerror) enumeration. - #### Example - ```js - // I have a context object, and I want to know what I can do with it, hence, I look for intents... - const appIntents = await fdc3.findIntentsByContext(context); - - // returns, for example: - // [ - // { - // intent: { name: "StartCall", displayName: "Call" }, - // apps: [{ name: "Skype" }] - // }, - // { - // intent: { name: "StartChat", displayName: "Chat" }, - // apps: [ - // { name: "Skype" }, - // { name: "Symphony" }, - // { name: "Symphony", instanceId: "93d2fe3e-a66c-41e1-b80b-246b87120859" }, - // { name: "Slack" } - // ] - // } - // ]; - - // select a particular intent to raise - const startChat = appIntents[1]; - - // target a particular app or instance - const selectedApp = startChat.apps[2]; - - // raise the intent, passing the given context, targeting the app - await fdc3.raiseIntent(startChat.intent.name, context, selectedApp); - ``` +The optional `resultType` argument may be a type name, the string `"channel"` (which indicates that the app will return a channel) or a string indicating a channel that returns a specific type, e.g. `"channel"`. If intent resolution to an app returning a channel is requested, the desktop agent MUST include both apps that are registered as returning a channel and those registered as returning a channel with a specific type in the response. + +#### Example +I have a context object, and I want to know what I can do with it, hence, I look for intents and apps to resolve them... + +```js +const appIntents = await fdc3.findIntentsByContext(context); + +// returns, for example: +// [ +// { +// intent: { name: "StartCall", displayName: "Call" }, +// apps: [{ name: "Skype" }] +// }, +// { +// intent: { name: "StartChat", displayName: "Chat" }, +// apps: [ +// { name: "Skype" }, +// { name: "Symphony" }, +// { name: "Symphony", instanceId: "93d2fe3e-a66c-41e1-b80b-246b87120859" }, +// { name: "Slack" } +// ] +// }, +// { +// intent: { name: "ViewContact", displayName: "View Contact" }, +// apps: [{ name: "Symphony" }, { name: "MyCRM", resultType: "fdc3.ContactList"}] +// } +// ]; +``` + +or I look for only intents that are resolved by apps returning a particular result type + +```js +const appIntentsForType = await fdc3.findIntentsByContext(context, "fdc3.ContactList"); +// returns for example: +// [{ +// intent: { name: "ViewContact", displayName: "View Contact" }, +// apps: [{ name: "Symphony" }, { name: "MyCRM", resultType: "fdc3.ContactList"}] +// }]; + +// select a particular intent to raise +const startChat = appIntents[1]; + +// target a particular app or instance +const selectedApp = startChat.apps[2]; + +// raise the intent, passing the given context, targeting the app +await fdc3.raiseIntent(startChat.intent.name, context, selectedApp); +``` #### See also - * [`findIntent()`](#findintent) - * [`ResolveError`](Errors#resolveerror) +* [`findIntent()`](#findintent) +* [`ResolveError`](Errors#resolveerror) @@ -312,7 +386,7 @@ getOrCreateChannel(channelId: string): Promise; ``` Returns a Channel object for the specified channel, creating it (as an _App_ channel) - if it does not exist. -`Error` with a string from the [`ChannelError`](ChannelError) enumeration if channel could not be created or access was denied. +`Error` with a string from the [`ChannelError`](Errors#channelerror) enumeration if channel could not be created or access was denied. #### Example @@ -324,12 +398,62 @@ try { catch (err){ //app could not register the channel } - ``` #### See also * [`Channel`](Channel) +### `createPrivateChannel` + +```ts +createPrivateChannel(): Promise; +``` + +Returns a `Channel` with an auto-generated identity that is intended for private communication between applications. Primarily used to create Channels that will be returned to other applications via an IntentResolution for a raised intent. + +If the `PrivateChannel` cannot be created, the returned promise MUST be rejected with an error string from the [`ChannelError`](Errors#channelerror) enumeration. + +The `PrivateChannel` type is provided to support synchronisation of data transmitted over returned channels, by allowing both parties to listen for events denoting subscription and unsubscription from the returned channel. `PrivateChannels` are only retrievable via raising an intent. + +It is intended that Desktop Agent implementations: +- SHOULD restrict external apps from listening or publishing on this channel. +- MUST prevent private channels from being retrieved via fdc3.getOrCreateChannel. +- MUST provide the `id` value for the channel as required by the Channel interface. + +#### Example + +```js +fdc3.addIntentListener("QuoteStream", async (context) => { + const channel = await fdc3.createPrivateChannel(); + const symbol = context.id.ticker; + + // This gets called when the remote side adds a context listener + const addContextListener = channel.onAddContextListener((contextType) => { + // broadcast price quotes as they come in from our quote feed + feed.onQuote(symbol, (price) => { + channel.broadcast({ type: "price", price}); + }); + }); + + // This gets called when the remote side calls Listener.unsubscribe() + const unsubscriberListener = channel.onUnsubscribe((contextType) => { + feed.stop(symbol); + }); + + // This gets called if the remote side closes + const disconnectListener = channel.onDisconnect(() => { + feed.stop(symbol); + }); + + return channel; +}); +``` + +#### See also +* [`PrivateChannel`](PrivateChannel) +* [`raiseIntent`](#raiseintent) +* [`addIntentListener`](#addintentlistener) + ### `getSystemChannels` ```ts getSystemChannels() : Promise>; @@ -450,14 +574,16 @@ raiseIntent(intent: string, context: Context, app?: TargetApp): Promise; + readonly intent: IntentMetadata; + readonly apps: Array; } ``` An interface that represents the binding of an intent to apps, returned as part of intent disocvery. @@ -46,7 +46,7 @@ interface AppMetadata { /** A tooltip for the application that can be used to render UI elements */ readonly tooltip?: string; - /** A longer, multi-paragraph description for the application that could include markup */ + /** A longer, multi-paragraph description for the application that could include mark-up */ readonly description?: string; /** A list of icon URLs for the application that can be used to render UI elements */ @@ -54,6 +54,12 @@ interface AppMetadata { /** A list of image URLs for the application that can be used to render UI elements */ readonly images?: Array; + + /** The type of result returned for any intent specified during resolution. + * May express a particular context type (e.g. "fdc3.instrument"), channel + * (e.g. "channel") or a channel that will receive a specified type + * (e.g. "channel"). */ + readonly resultType?: string | null; } ``` @@ -63,9 +69,7 @@ Will always includes at least a `name` property, which can be used with [`open`] Optionally, extra information from the app directory can be returned, to aid in rendering UI elements, e.g. a context menu. This includes a title, description, tooltip and icon and image URLs. -In situations where a desktop agent connects to multiple app directories or multiple versions of the same app exists in a single app directory, it may be neccessary to specify appId and version to target applications that share the same name. - -`AppMetadata` +In situations where a desktop agent connects to multiple app directories or multiple versions of the same app exists in a single app directory, it may be necessary to specify `appId` or `version` to target applications that share the same name. #### See also * [`AppIntent.apps`](AppIntent) @@ -76,41 +80,24 @@ In situations where a desktop agent connects to multiple app directories or mult ## `DisplayMetadata` ```ts - public interface DisplayMetadata { - name?: string; - color?: string; - glyph?: string; +interface DisplayMetadata { + /** + * A user-readable name for this channel, e.g: `"Red"` + */ + readonly name?: string; + /** + * The color that should be associated within this channel when displaying this channel in a UI, e.g: `0xFF0000`. + */ + readonly color?: string; + /** + * A URL of an image that can be used to display this channel + */ + readonly glyph?: string; } ``` A desktop agent (typically for _system_ channels) may want to provide additional information about how a channel can be represented in a UI. A common use case is for color linking. -#### Properties - -#### `name` - -```ts -name?: string; -``` - -The display name for the channel. - -#### `color` - -```ts -color?: string; -``` - -A name, hex, rgba, etc. that should be associated within the channel when displaying it in a UI. - -#### `glyph` - -```ts -glyph: string; -``` - -A URL of an image that can be used to display this channel. - #### See also * [`Channel`](Channel) @@ -118,11 +105,16 @@ A URL of an image that can be used to display this channel. ## `ImplementationMetadata` -```typescript -public interface ImplementationMetadata { - fdc3Version: string; - provider: string; - providerVersion?: string; +```ts +interface ImplementationMetadata { + /** The version number of the FDC3 specification that the implementation provides. + * The string must be a numeric semver version, e.g. 1.2 or 1.2.1. + */ + readonly fdc3Version: string; + /** The name of the provider of the FDC3 Desktop Agent Implementation (e.g. Finsemble, Glue42, OpenFin etc.). */ + readonly provider: string; + /** The version of the provider of the FDC3 Desktop Agent Implementation (e.g. 5.3.0). */ + readonly providerVersion?: string; } ``` @@ -135,8 +127,10 @@ Metadata relating to the FDC3 [DesktopAgent](DesktopAgent) object and its provid ```ts interface IntentMetadata { - name: string; - displayName: string; + /** The unique name of the intent that can be invoked by the raiseIntent call */ + readonly name: string; + /** A friendly display name for the intent that should be used to render UI elements */ + readonly displayName: string; } ``` @@ -150,6 +144,7 @@ The interface used to describe an intent within the platform. ```ts interface IntentResolution { + /** * Metadata about the app instance that was selected (or started) to resolve the intent. * `source.instanceId` MUST be set, indicating the specific app instance that @@ -166,15 +161,21 @@ interface IntentResolution { */ readonly version?: string; /** - * Retrieves a promise that will resolve to data returned by the - * application that resolves the raised intent. If an error occurs - * (i.e. an error is thrown by the handler function, the promise - * returned by the handler function is rejected, or no promise is - * returned) then the Desktop Agent MUST reject the promise - * returned by the `getResult()` function of the `IntentResolution` - * with a string from the `DataError` enumeration. + * Retrieves a promise that will resolve to either `Context` data returned + * by the application that resolves the raised intent or a `Channel` + * established and returned by the app resolving the intent. + * + * A `Channel` returned MAY be of the `PrivateChannel` type. The + * client can then `addContextListener()` on that channel to, for example, + * receive a stream of data. + * + * If an error occurs (i.e. an error is thrown by the handler function, + * the promise it returns is rejected, or a promise is not returned by the + * handler function) then the Desktop Agent MUST reject the promise returned + * by the `getResult()` function of the `IntentResolution` with a string from + * the `ResultError` enumeration. */ - getResult(): Promise; + getResult(): Promise; } ``` @@ -193,13 +194,19 @@ try { //some time later await agent.raiseIntent("UpdateOrder", context, resolution.source); } -catch (err) { ... } - -//resolve a "Client-Service" type intent with a data response +catch (err) { ... } + +//resolve a "Client-Service" type intent with a data or channel response let resolution = await agent.raiseIntent("intentName", context); try { const result = await resolution.getResult(); - console.log(`${resolution.source} returned ${JSON.stringify(result)}`); + if (result && result.broadcast) { //detect whether the result is Context or a Channel + console.log(`${resolution.source} returned a channel with id ${result.id}`); + } else if (){ + console.log(`${resolution.source} returned data: ${JSON.stringify(result)}`); + } else { + console.error(`${resolution.source} didn't return anything` + } } catch(error) { console.error(`${resolution.source} returned an error: ${error}`); } diff --git a/docs/api/ref/PrivateChannel.md b/docs/api/ref/PrivateChannel.md new file mode 100644 index 000000000..cddbce688 --- /dev/null +++ b/docs/api/ref/PrivateChannel.md @@ -0,0 +1,144 @@ +--- +id: PrivateChannel +sidebar_label: PrivateChannel +title: PrivateChannel +hide_title: true +--- +# `PrivateChannel` + +Object representing a private context channel, which is intended to support secure communication between applications, and extends the Channel interface with event handlers which provide information on the connection state of both parties, ensuring that desktop agents do not need to queue or retain messages that are broadcast before a context listener is added and that applications are able to stop broadcasting messages when the other party has disconnected. + +It is intended that Desktop Agent implementations: +- SHOULD restrict external apps from listening or publishing on this channel. +- MUST prevent private channels from being retrieved via fdc3.getOrCreateChannel. +- MUST provide the `id` value for the channel as required by the Channel interface. + +```ts +interface PrivateChannel extends Channel { + // methods + onAddContextListener(handler: (contextType?: string) => void): Listener; + onUnsubscribe(handler: (contextType?: string) => void): Listener; + onDisconnect(handler: () => void): Listener; + disconnect(): void; +} +``` + +#### See also + +* [`Channel`](Channel) +* [`Listener`](Types#listener) +* [`DesktopAgent.addIntentListener`](DesktopAgent#addintentlistener) +* [`DesktopAgent.createPrivateChannel`](DesktopAgent#createPrivateChannel) +* [`DesktopAgent.raiseIntent`](DesktopAgent#raiseintent) + +## Examples +### 'Server-side' example: +The intent app establishes and returns a PrivateChannel to the client (who is awaiting `getResult()`). When the client calls `addContextlistener()` on that channel, the intent app receives notice via the handler added with `onAddContextListener()` and knows that the client is ready to start receiving quotes. + +The Desktop Agent knows that a channel is being returned by inspecting the object returned from the handler (e.g. check constructor or look for private member). + +```javascript +fdc3.addIntentListener("QuoteStream", async (context) => { + const channel: PrivateChannel = await fdc3.createPrivateChannel(); + const symbol = context.id.ticker; + + // This gets called when the remote side adds a context listener + const addContextListener = channel.onAddContextListener((contextType) => { + // broadcast price quotes as they come in from our quote feed + feed.onQuote(symbol, (price) => { + channel.broadcast({ type: "price", price}); + }); + }); + + // This gets called when the remote side calls Listener.unsubscribe() + const unsubscriberListener = channel.onUnsubscribe((contextType) => { + feed.stop(symbol); + }); + + // This gets called if the remote side closes + const disconnectListener = channel.onDisconnect(() => { + feed.stop(symbol); + }); + + return channel; +}); +``` + +### 'Client-side' example: + +```javascript +try { + const resolution3 = await fdc3.raiseIntent("QuoteStream", { type: "fdc3.instrument", id : { symbol: "AAPL"}}); + try { + const result = await resolution3.getResult(); + //check that we got a result and that its a channel + if (result && result.addContextListener) { + const listener = result.addContextListener("price", (quote) => console.log(quote)); + result.onDisconnect(() => { + console.warn("Quote feed went down"); + }); + + // Sometime later... + listener.unsubscribe(); + } else { + console.warn(`${resolution3.source} did not return a channel`); + } + } catch(channelError){ + console.log(`Error: ${resolution3.source} returned an error: ${channelError}`); + } +} catch (resolverError) { + console.error(`Error: Intent was not resolved: ${resolverError}`); +} +``` + + +## Methods + +### `onAddContextListener` +```ts +onAddContextListener(handler: (contextType?: string) => void): Listener; +``` +Adds a listener that will be called each time that the remote app invokes addContextListener on this channel. + +Desktop Agents MUST call this for each invokation of addContextListener on this channel, including those that occurred before this handler was registered (to prevent race conditions). + +#### See also +* [`Channel.addContextListener`](Channel#addcontextlistener) + +### `onUnsubscribe` + +```ts +onUnsubscribe(handler: (contextType?: string) => void): Listener; +``` + +Adds a listener that will be called whenever the remote app invokes `Listener.unsubscribe()` on a context listener that it previously added. + +Desktop Agents MUST call this when disconnect() is called by the other party, for each listner that they had added. + +#### See also +* [`Listener`](Types#listener) + +### `onDisconnect` + +```ts +onDisconnect(handler: () => void): Listener; +``` + +Adds a listener that will be called when the remote app terminates, for example when its window is closed or because disconnect was called. This is in addition to calls that will be made to an onUnsubscribe listeners. + +#### See also +* [`disconnect`](#disconnect) + +### `disconnect` + +```ts +disconnect(): void; +``` + +May be called to indicate that a participant will no longer interact with this channel. + +After this function has been called, Desktop Agents SHOULD prevent apps from broadcasting on this channel and MUST automatically call Listener.unsubscribe() for each listener that they've added (causing any `onUnsubscribe` handler added by the other party to be called) before triggering any onDisconnect handler added by the other party. + +#### See also +* [`onUnsubscribe`](#onunsubscribe) +* [`Listener`](Types#listener) \ No newline at end of file diff --git a/docs/api/ref/Types.md b/docs/api/ref/Types.md index 365bcc3f9..85fdaa0f6 100644 --- a/docs/api/ref/Types.md +++ b/docs/api/ref/Types.md @@ -51,18 +51,38 @@ Used when attaching listeners for context broadcasts. ## `IntentHandler` ```typescript -type IntentHandler = (context: Context) => Promise | void; +type IntentHandler = (context: Context) => Promise | void; ``` -Describes a callback that handles a context event and may return a promise of a Context object to be returned to the application that raised the intent. +Describes a callback that handles a context event and may return a promise of a Context or Channel object to be returned to the application that raised the intent. Used when attaching listeners for raised intents. #### See also * [`Context`](#context) +* [`PrivateChannel`](PrivateChannel) * [`DesktopAgent.addIntentListener`](DesktopAgent#addintentlistener) * [`Channel.addContextListener`](Channel#addcontextlistener) +## `IntentResult` + +```typescript +type IntentResult = Context | Channel; +``` + +Describes results that an Intent handler may optionally return that should be communicated back to the app that raised the intent, via the [`IntentResolution`](Metadata#intentresolution). + +Represented as a union type in Typescript, however, this type may be rendered as an interface in other languages that both the Context and Channel types implement, allowing either to be returned by an intent handler. + +#### See also +* [`Context`](#context) +* [`Channel`](Channel) +* [`PrivateChannel`](PrivateChannel) +* [`IntentHandler`](#intenthandler) +* [`DesktopAgent.addIntentListener`](DesktopAgent#addintentlistener) +* [`IntentResolution`](Metadata#intentresolution) + + ## `Listener` A Listener object is returned when an application subscribes to intents or context broadcasts via the [`addIntentListener`](#addintentlistener) or [`addContextListener`](#addcontextlistener) methods on the [DesktopAgent](DesktopAgent) object. diff --git a/docs/api/spec.md b/docs/api/spec.md index 068142a14..d7059760d 100644 --- a/docs/api/spec.md +++ b/docs/api/spec.md @@ -23,6 +23,7 @@ The FDC3 API specification consists of interfaces. It is expected that each Des - `DesktopAgent` - `Channel` +- `PrivateChannel` - `Listener` Other interfaces defined in the spec are not critical to define as concrete types. Rather, the Desktop Agent should expect to have objects of the interface shape passed into or out of their library. These interfaces include: @@ -103,10 +104,13 @@ When raising an intent a specific context may be provided as input. The type of A context type may also be associated with multiple intents. For example, an `fdc3.instrument` could be associated with `ViewChart`, `ViewNews`, `ViewAnalysis` or other intents. In addition to raising a specific intent, you can raise an intent for a specific context allowing the Desktop Agent or the user (if the intent is ambiguous) to select the appropriate intent for the selected context and then to raise that intent for resolution. -To raise an intent without a context, use the `fdc3.nothing` context type. This type exists so that applications can explicitly declare that they support raising an intent without a context (when registering an intent listener or in an App Directory). +To raise an Intent without a context, use the [`fdc3.nothing`](../context/ref/Nothing) context type. This type exists so that applications can explicitly declare that they support raising an intent without a context (when registering an Intent listener or in an App Directory). -An optional context object may also be returned as output by an application resolving an intent. For example, an application resolving a `CreateOrder` intent might return a context representing the order and including an ID, allowing the application that raised the intent to make further calls using that ID. +An optional [`IntentResult`](ref/Types#intentresult) may also be returned as output by an application handling an intent. Results maybe either a single `Context` object or a `Channel`, that may be used to send a stream of responses. The [`PrivateChannel`](ref/PrivateChannel) type is provided to support synchronisation of data transmitted over returned channels, by allowing both parties to listen for events denoting subscription and unsubscription from the returned channel. `PrivateChannels` are only retrievable via [raising an intent](ref/DesktopAgent#raiseintent). +For example, an application handling a `CreateOrder` intent might return a context representing the order and including an ID, allowing the application that raised the intent to make further calls using that ID. + +An optional result type is also supported when programmatically resolving an intent via [`findIntent`](ref/DesktopAgent#findintent) or [`findIntentByContext`](ref/DesktopAgent#findintentbycontext). #### Intent Resolution Raising an intent will return a Promise-type object that will resolve/reject based on a number of factors. @@ -121,7 +125,7 @@ Raising an intent will return a Promise-type object that will resolve/reject bas ##### Resolution Object -If the raising of the intent resolves (or rejects), a standard object will be passed into the resolver function with the following format: +If the raising of the intent resolves (or rejects), a standard [`IntentResolution`](ref/Metadata#intentresolution) object will be passed into the resolver function with the following format: ```ts interface IntentResolution { @@ -141,13 +145,20 @@ interface IntentResolution { */ readonly version?: string; /** - * Retrieves a promise that will resolve to data returned by the application that - * resolves the raised intent. If an error occurs (i.e. an error is thrown by the handler - * function, the promise returned is rejected, or no promise is returned) then the Desktop - * Agent MUST reject the promise returned by the `getResult()` function of the - * `IntentResolution` with a string from the `DataError` enumeration. + * Retrieves a promise that will resolve to either `Context` data returned + * by the application that resolves the raised intent or a `Channel` + * established and returned by the app resolving the intent. + * + * A `Channel` returned will often be of the `PrivateChannel` type. The + * client can then `addContextListener()` on that channel to, for example, + * receive a stream of data. + * + * The promise MUST reject with a string from the `ResultError` enumeration + * if an error is thrown by the intent handler, it rejects the returned + * promise, it does not return a promise or the promise resolves to an + * object of an invalid type. */ - getResult(): Promise; + getResult(): Promise; } ``` @@ -184,19 +195,25 @@ try { catch (err) { ... } ``` -Raise an intent and retrieve data from the IntentResolution: +Raise an intent and retrieve either data or a channel from the IntentResolution: ```js let resolution = await agent.raiseIntent("intentName", context); try { - const result = await resolution.getResult(); - console.log(`${resolution.source} returned ${JSON.stringify(result)}`); + const result = await resolution.getResult(); + if (result && result.broadcast) { //detect whether the result is Context or a Channel + console.log(`${resolution.source} returned a channel with id ${result.id}`); + } else if (){ + console.log(`${resolution.source} returned data: ${JSON.stringify(result)}`); + } else { + console.error(`${resolution.source} didn't return anything` + } } catch(error) { - console.error(`${resolution.source} returned a data error: ${error}`); + console.error(`${resolution.source} returned a data error: ${error}`); } ``` #### Resolvers -Intents functionality is dependent on resolver functionality to map the intent to a specific App. This will often require end-user input. Resolution can either be performed by the Desktop Agent (for example, by displaying a resolver UI allowing the user to pick the desired app or app instance for the intent) or by the app handling the resolution itself (by using the `findIntents` API and specifying a target app or app instance when invoking the Intent), e.g.: +Successful delivery of an intent depends first upon the Desktop Agent's ability to "resolve the intent" (i.e. map the intent to a specific App instance). Desktop Agents may resolve intents by any methodology. A common methodology is to display a UI that allows the user to pick the desired App for a given intent. Alternatively, the intent issuing app may proactively handle resolution by calling [`findIntent`](ref/DesktopAgent#findintent) or [`findIntentByContext`](ref/DesktopAgent#findintentbycontext) and then raising the Intent with a specific target application, e.g.: ```js // Find apps to resolve an intent to start a chat with a given contact @@ -204,8 +221,41 @@ const appIntent = await fdc3.findIntent("StartChat", context); // use the returned AppIntent object to target one of the returned // chat apps or app instances using the AppMetadata object await fdc3.raiseIntent("StartChat", context, appIntent.apps[0]); + +//Find apps to resolve an intent and return a specified context type +const appIntent = await fdc3.findIntent("ViewContact", context, "fdc3.contact"); +try { + const resolution = await fdc3.raiseIntent(appIntent.intent, context, appIntent.apps[0].name); + const result = await resolution.getResult(); + console.log(`${resolution.source} returned ${JSON.stringify(result)}`); +} catch(error) { + console.error(`${resolution.source} returned a result error: ${error}`); +} + +//Find apps to resolve an intent and return a channel +const appIntent = await fdc3.findIntent("QuoteStream", context, "channel"); +try { + const resolution = await fdc3.raiseIntent(appIntent.intent, context, appIntent.apps[0].name); + const result = await resolution.getResult(); + if (result && result.addContextListener){ + result.addContextListener(null, (context) => { + console.log(`received context: ${JSON.stringify(context)}`); + }); + } else { + console.log(`${resolution.source} didn't return a channel! Result: ${JSON.stringify(result)}`); + } +} catch(error) { + console.error(`${resolution.source} returned a result error: ${error}`); +} + +//Find apps that can perform any intent with the specified context +const appIntents = await fdc3.findIntentByContext(context); +//use the returned AppIntent array to target one of the returned apps +await fdc3.raiseIntent(appIntent[0].intent, context, appIntent[0].apps[0]); ``` +Result context types requested are represented by their type name. A channel may be requested by passing the string `"channel"` or a channel that returns a specific type via the syntax `"channel"`, e.g. `"channel"`. Requesting intent resolution to an app returning a channel MUST include apps that are registered as returning a channel with a specific type. + #### Upgrading to a Remote API Connection There are a wide range of workflows where decoupled intents and/or context passing do not provide rich enough interactivity and applications are better off exposing proprietary APIs. In these cases, an App can use the *source* property on the resolution of an intent to connect directly to another App and from there, call remote APIs using the methods available in the Desktop Agent context for the App. For example: @@ -217,16 +267,18 @@ const chartApp = fin.Application.wrap(chart.source); ``` ![Upgrading Connection to Remote API](assets/api-3.png) -### Register an Intent -Applications need to let the system know the intents they can support. Typically, this is done via registration with the App Directory. It is also possible for intents to be registered at the application level as well to support ad-hoc registration which may be helpful at development time. While dynamic registration is not part of this specification, a Desktop Agent agent may choose to support any number of registration paths. +### Register an Intent Handler +Applications need to let the system know the intents they can support. Typically, this is done via registration with an App Directory. It is also possible for intents to be registered at the application level as well to support ad-hoc registration which may be helpful at development time. While, dynamic registration is not part of this specification, a Desktop Agent agent may choose to support any number of registration paths. #### Compliance with Intent Standards -Intents represent a contract with expected behavior if an app asserts that it supports the intent. Where this contract is enforceable by schema (for example, return object types), the FDC3 API implementation should enforce compliance and return an error if the interface is not met. +Intents represent a contract with expected behaviour if an app asserts that it supports the intent. Where this contract is enforceable by schema (for example, return object types), the FDC3 API implementation should enforce compliance and return an error if the interface is not met. It is expected that App Directories will also curate listed apps and ensure that they are complying with declared intents. ### Send/broadcast Context -On the financial desktop, applications often want to broadcast context to any number of applications. Context sharing needs to support concepts of different groupings of applications as well as data privacy concerns. Each Desktop Agent will have its own rules for supporting these features. However, a Desktop Agent should ensure that context messages broadcast to a channel by an application joined to it should not be delivered back to that same application. +On the financial desktop, applications often want to broadcast context to any number of applications. Context sharing needs to support concepts of different groupings of applications as well as data privacy concerns. Each Desktop Agent will have its own rules for supporting these features. However, a Desktop Agent SHOULD ensure that context messages broadcast to a channel by an application joined to it are not delivered back to that same application. + +In some cases, application want to communicate with a single application or service and to prevent other applications from participating in the communication. For single transactions, this can be implemented via a raised intent, which will be delivered to single application that can, optionally, respond with data. Alternatively, it may instead respond with a [`Channel`](ref/Channel) or [`PrivateChannel`](ref/PrivateChannel) over which a stream of responses or a dialog can be supported. ### Retrieve Metadata about the Desktop Agent implementation From version 1.2 of the FDC3 specification, Desktop Agent implementations MUST provide a `fdc3.getInfo()` function to allow apps to retrieve information about the version of the FDC3 specification supported by a Desktop Agent implementation and the name of the implementation provider. This metadata can be used to vary the behavior of an application based on the version supported by the Desktop Agent, e.g.: @@ -245,7 +297,8 @@ if (fdc3.getInfo && versionIsAtLeast(await fdc3.getInfo(), '1.2')) { Context channels allows a set of apps to share a stateful piece of data between them, and be alerted when it changes. Use cases for channels include color linking between applications to automate the sharing of context and topic based pub/sub such as theme. -There are two types of channels, which are functionally identical, but have different visibility and discoverability semantics: + +There are three types of channels, which have different visibility and discoverability semantics: 1. **_System channels_**, which: * facilitate the creation of user-controlled context links between applications (often via the selection of a color channel), @@ -263,6 +316,8 @@ There are two types of channels, which are functionally identical, but have diff * are interacted with via the [Channel API](ref/Channel) (accessed via the desktop agent [`getOrCreateChannel`](ref/DesktopAgent#getorcreatechannel) API call) +3. 'private' channels, which support private communication between two parties, have an auto-generated identity and can only be retrieved via a raised intent. + Channels are interacted with via `broadcast` and `addContextListener` functions, allowing an application to send and receive Context objects via the channel. For System channels, these functions are provided on the Desktop Agent, e.g. [`fdc3.broadcast(context)`](ref/DesktopAgent#broadcast), and apply to channels joined via [`fdc3.joinChannel`](ref/DesktopAgent#joinchannel). For App channels, a channel object must be retrieved, via [`fdc3.getOrCreateChannel(channelName)`](ref/DesktopAgent#getorcreatechannel), which provides the functions, e.g. [`myChannel.broadcast(context)`](ref/Channel#broadcast). Channel implementations should ensure that context messages broadcast by an application on a channel are not delivered back to that same application if they are also listening on the channel. @@ -375,10 +430,21 @@ joinedChannel = await fdc3.getCurrentChannel() if another application broadcasts to "my_custom_channel" (by retrieving it and broadcasting to it via `myChannel.broadcast()`) then the broadcast will be received by the specific listener (`myChannelListener`) but NOT by the listener for joined channels (`listener`). +### Private Channels + +Private Channels are created to support the return of a stream of responses from a raised intent, or private dialog between two applications. + +It is intended that Desktop Agent implementations: + * - SHOULD restrict external apps from listening or publishing on this channel. + * - MUST prevent private channels from being retrieved via fdc3.getOrCreateChannel. + * - MUST provide the `id` value for the channel as required by the Channel interface. + +The `PrivateChannel` type also supports synchronisation of data transmitted over returned channels. They do so by extending the Channel interface with event handlers which provide information on the connection state of both parties, ensuring that desktop agents do not need to queue or retain messages that are broadcast before a context listener is added and that applications are able to stop broadcasting messages when the other party has disconnected. + ### Broadcasting and listening for multiple context types The [Context specification](../../context/spec#assumptions) recommends that complex context objects are defined using simpler context types for particular fields. For example, a `Position` is composed of an `Instrument` and a holding amount. This leads to situations where an application may be able to receive or respond to context objects that are embedded in a more complex type, but not the more complex type itself. For example, a pricing chart might respond to an `Instrument` but doesn't know how to handle a `Position`. -To facilitate context linking in such situations it is recommended that applications `broadcast` each context type that other apps (listening on a System channel or App channel) may wish to process, starting with the simpler types, followed by the complex type. Doing so allows applications to filter the context types they receive by adding listeners for specific context types - but requires that the application broadcasting context make multiple broadcast calls in quick succession when sharing its context. +To facilitate context linking in such situations it is recommended that applications [`broadcast`](ref/DesktopAgent#broadcast) each context type that other apps (listening on a System channel or App channel) may wish to process, starting with the simpler types, followed by the complex type. Doing so allows applications to filter the context types they receive by adding listeners for specific context types - but requires that the application broadcasting context make multiple broadcast calls in quick succession when sharing its context. ## APIs The APIs are defined in TypeScript in [src], with documentation generated in the [docs] folder. diff --git a/src/api/AppMetadata.ts b/src/api/AppMetadata.ts index c4acdadfc..dd9a2ec7b 100644 --- a/src/api/AppMetadata.ts +++ b/src/api/AppMetadata.ts @@ -44,4 +44,7 @@ export interface AppMetadata { /** A list of image URLs for the application that can be used to render UI elements */ readonly images?: Array; + + /** The type of output returned for any intent specified during resolution. May express a particular context type (e.g. "fdc3.instrument"), channel (e.g. "channel") or a channel that will receive a specified type (e.g. "channel"). */ + readonly resultType?: string | null; } diff --git a/src/api/Channel.ts b/src/api/Channel.ts index f4a09a842..9ada7cea1 100644 --- a/src/api/Channel.ts +++ b/src/api/Channel.ts @@ -19,6 +19,7 @@ export interface Channel { /** * Uniquely defines each channel type. + * Can be "system", "app" or "private" */ readonly type: string; diff --git a/src/api/DesktopAgent.ts b/src/api/DesktopAgent.ts index cc3fe9b74..0a989237d 100644 --- a/src/api/DesktopAgent.ts +++ b/src/api/DesktopAgent.ts @@ -1,6 +1,6 @@ /** * SPDX-License-Identifier: Apache-2.0 - * Copyright 2019 FINOS FDC3 contributors - see NOTICE file + * Copyright 2019-2021 FINOS FDC3 contributors - see NOTICE file */ import { AppIntent } from './AppIntent'; @@ -10,6 +10,7 @@ import { IntentResolution } from './IntentResolution'; import { Listener } from './Listener'; import { Context } from '../context/ContextTypes'; import { ImplementationMetadata } from './ImplementationMetadata'; +import { PrivateChannel } from './PrivateChannel'; import { AppMetadata } from './AppMetadata'; /** @@ -49,7 +50,7 @@ export interface DesktopAgent { open(app: TargetApp, context?: Context): Promise; /** - * Find out more information about a particular intent by passing its name, and optionally its context. + * Find out more information about a particular intent by passing its name, and optionally its context and/or a desired result type. * * findIntent is effectively granting programmatic access to the Desktop Agent's resolver. * A promise resolving to the intent, its metadata and metadata about the apps and app instances that registered it is returned. @@ -57,8 +58,15 @@ export interface DesktopAgent { * * If the resolution fails, the promise will return an `Error` with a string from the `ResolveError` enumeration. * + * Output types may be a type name, the string "channel" (which indicates that the app + * will return a channel) or a string indicating a channel that returns a specific type, + * e.g. "channel". + * If intent resolution to an app returning a channel is requested, the desktop agent + * MUST include both apps that are registered as returning a channel and those registered + * as returning a channel with a specific type in the response. + * * ```javascript - * // I know 'StartChat' exists as a concept, and want to know more about it ... + * // I know 'StartChat' exists as a concept, and want to know which apps can resolve it ... * const appIntent = await fdc3.findIntent("StartChat"); * * // returns a single AppIntent: @@ -87,19 +95,52 @@ export interface DesktopAgent { * // { name: "Slack" } * // ] * ``` + * + * An optional input context object and result type may be specified, which the resolver MUST use to filter the returned applications such that each supports the specified input and result types. + * + * ```javascript + * const appIntent = await fdc3.findIntent("StartChat", contact); + * + * // returns only apps that support the type of the specified input context: + * // { + * // intent: { name: "StartChat", displayName: "Chat" }, + * // apps: { name: "Symphony" }] + * // } + * + * const appIntent = await fdc3.findIntent("ViewContact", contact, "fdc3.ContactList"); + * + * // returns only apps that return the specified result Context type: + * // { + * // intent: { name: "ViewContact", displayName: "View Contact Details" }, + * // apps: { name: "MyCRM", resultType: "fdc3.ContactList"}] + * // } + * + * const appIntent = await fdc3.findIntent("QuoteStream", instrument, "channel"); + * + * // returns only apps that return a channel which will receive the specified input and result types: + * // { + * // intent: { name: "QuoteStream", displayName: "Quotes stream" }, + * // apps: { name: "MyOMS", resultType: "channel"}] + * // } + * ``` */ - findIntent(intent: string, context?: Context): Promise; + findIntent(intent: string, context?: Context, resultType?: string): Promise; /** - * Find all the available intents for a particular context. + * Find all the available intents for a particular context, and optionally a desired result context type. * * findIntents is effectively granting programmatic access to the Desktop Agent's resolver. * A promise resolving to all the intents, their metadata and metadata about the apps and app instance that registered it is returned, based on the context types the intents have registered. * * If the resolution fails, the promise will return an `Error` with a string from the `ResolveError` enumeration. * + * Result types may be a type name, the string "channel" (which indicates that the app should return a + * channel) or a string indicating a channel that returns a specific type, e.g. "channel". + * If intent resolution to an app returning a channel is requested, the desktop agent MUST also include apps + * that are registered as returning a channel with a specific type in the response. + * * ```javascript - * // I have a context object, and I want to know what I can do with it, hence, I look for for intents... + * // I have a context object, and I want to know what I can do with it, hence, I look for intents and apps to resolve them... * const appIntents = await fdc3.findIntentsByContext(context); * * // returns for example: @@ -119,17 +160,25 @@ export interface DesktopAgent { * // } * // ]; * + * // or I look for only intents that are resolved by apps returning a particular result type + * const appIntentsForType = await fdc3.findIntentsByContext(context, "fdc3.ContactList"); + * // returns for example: + * // [{ + * // intent: { name: "ViewContact", displayName: "View Contacts" }, + * // apps: [{ name: "MyCRM", resultType: "fdc3.ContactList"}] + * // }]; + * * // select a particular intent to raise - * const startChat = appIntents[1]; + * const resolvedIntent = appIntents[1]; * * // target a particular app or instance - * const selectedApp = startChat.apps[2]; + * const selectedApp = resolvedIntent.apps[2]; * - * // raise the intent, passing the given context, targeting the app + * // raise the intent, passing the given context, targeting the app or app instance * await fdc3.raiseIntent(startChat.intent.name, context, selectedApp); * ``` */ - findIntentsByContext(context: Context): Promise>; + findIntentsByContext(context: Context, resultType?: string): Promise>; /** * Find all the available instances for a particular application. @@ -173,11 +222,13 @@ export interface DesktopAgent { * The desktop agent MUST resolve the correct app to target based on the provided intent name and context data. If multiple matching apps are found, the user MAY be presented with a Resolver UI allowing them to pick one, or another method of Resolution applied to select an app. * Alternatively, the specific app or app instance to target can also be provided. A list of valid target applications and instances can be retrieved via `findIntent`. * + * If a target app for the intent cannot be found with the criteria provided or the user either closes the resolver UI or otherwise cancels resolution, an `Error` with a string from the `ResolveError` enumeration is returned. If a specific target `app` parameter was set, but either the app or app instance is not available then the `ResolveError.TargetAppUnavailable` or `ResolveError.TargetInstanceUnavailable` errors MUST be returned. + * * If you wish to raise an Intent without a context, use the `fdc3.nothing` context type. This type exists so that apps can explicitly declare support for raising an intent without context. * - * Returns an `IntentResolution` object with details of the app instance that was selected (or started) to respond to the intent. If the application that resolves the intent returns a promise of context data, this may be retrieved via the `getResult()` function of the `IntentResolution` object. If an error occurs (i.e. an error is thrown by the handler function, the promise returned is rejected, or no promise is returned) then the Desktop Agent MUST reject the promise returned by the `getResult()` function of the `IntentResolution` with a string from the `DataError` enumeration. + * Returns an `IntentResolution` object with details of the app instance that was selected (or started) to respond to the intent. * - * If a target app for the intent cannot be found with the criteria provided, an `Error` with a string from the `ResolveError` enumeration MUST be returned. + * Issuing apps may optionally wait on the promise that is returned by the `getResult()` member of the `IntentResolution`. This promise will resolve when the _receiving app's_ intent handler function returns and resolves a promise. The Desktop Agent resolves the issuing app's promise with the Context object or Channel that is provided as resolution within the receiving app. The Desktop Agent MUST reject the issuing app's promise, with a string from the `ResultError` enumeration, if: (1) the intent handling function's returned promise rejects, (2) the intent handling function doesn't return a promise, or (3) the returned promise resolves to an invalid type. * * ```javascript * // raise an intent for resolution by the desktop agent @@ -193,33 +244,40 @@ export interface DesktopAgent { * //Raise an intent without a context by using the null context type * await fdc3.raiseIntent("StartChat", {type: "fdc3.nothing"}); * - * //Raise an intent and retrieve data from the IntentResolution + * //Raise an intent and retrieve a result from the IntentResolution * let resolution = await agent.raiseIntent("intentName", context); * try { * const result = await resolution.getResult(); - * console.log(`${resolution.source} returned ${JSON.stringify(result)}`); + * if (result && result.broadcast) { //detect whether the result is Context or a Channel + * console.log(`${resolution.source} returned a channel with id ${result.id}`); + * } else if (){ + * console.log(`${resolution.source} returned data: ${JSON.stringify(result)}`); + * } else { + * console.error(`${resolution.source} didn't return anything` + * } * } catch(error) { - * console.error(`${resolution.source} returned a data error: ${error}`); + * console.error(`${resolution.source} returned an error: ${error}`); * } * ``` */ raiseIntent(intent: string, context: Context, app?: TargetApp): Promise; /** - * Finds and raises an intent against apps registered with the desktop agent based purely on the type of the context data. + * Finds and raises an intent against apps registered with the desktop agent based on the type of the specified context data example. * * The desktop agent SHOULD first resolve to a specific intent based on the provided context if more than one intent is available for the specified context. This MAY be achieved by displaying a resolver UI. It SHOULD then resolve to a specific app to handle the selected intent and specified context. * Alternatively, the specific app or app instance to target can also be provided, in which case the resolver SHOULD only offer intents supported by the specified application. * * Using `raiseIntentForContext` is similar to calling `findIntentsByContext`, and then raising an intent against one of the returned apps, except in this case the desktop agent has the opportunity to provide the user with a richer selection interface where they can choose both the intent and target app. * - * Returns an `IntentResolution` object with details of the app that was selected to respond to the intent. If the application that resolves the intent returns a promise of Context data, this may be retrieved via the `getResult()` function of the IntentResolution object. If an error occurs (i.e. an error is thrown by the handler function, the promise returned is rejected, or no promise is returned) then the Desktop Agent MUST reject the promise returned by the `getResult()` function of the `IntentResolution` with a string from the `DataError` enumeration. + * Returns an `IntentResolution` object, see `raiseIntent()` for details. * * If a target app for the intent cannot be found with the criteria provided, an `Error` with a string from the `ResolveError` enumeration is returned. * * ```javascript - * // Resolve against all intents registered for the specified context + * // Resolve against all intents registered for the type of the specified context * await fdc3.raiseIntentForContext(context); + * * // Resolve against all intents registered by a specific target app for the specified context * await fdc3.raiseIntentForContext(context, targetAppMetadata); * ``` @@ -227,12 +285,11 @@ export interface DesktopAgent { raiseIntentForContext(context: Context, app?: TargetApp): Promise; /** - * Adds a listener for incoming Intents from the Agent. The handler function may - * return void or a promise that should resolve to a context object representing - * any data that should be returned to app that raised the intent. If an error occurs - * (i.e. an error is thrown by the handler function, the promise returned is rejected, or - * a promise is not returned) then the Desktop Agent MUST reject the promise returned - * by the `getResult()` function of the `IntentResolution`. + * Adds a listener for incoming Intents from the Agent. The handler function may return void or a promise that should resolve to an `IntentResult`, which is either a `Context` object, representing any data that should be returned, or a `Channel` over which data responses will be sent. The IntentResult will be returned to app that raised the intent via the `IntentResolution` and retrieved from it using the `getResult()` function. + * + * The Desktop Agent MUST reject the promise returned by the `getResult()` function of `IntentResolution` if: (1) the intent handling function's returned promise rejects, (2) the intent handling function doesn't return a promise, or (3) the returned promise resolves to an invalid type. + * + * The `PrivateChannel` type is provided to support synchronisation of data transmitted over returned channels, by allowing both parties to listen for events denoting subscription and unsubscription from the returned channel. `PrivateChannels` are only retrievable via raising an intent. * * ```javascript * //Handle a raised intent @@ -248,6 +305,27 @@ export interface DesktopAgent { * resolve({type: "fdc3.order", id: { "orderId": 1234}}); * }); * }); + * + * //Handle a raised intent and return a Private Channel over which response will be sent + * fdc3.addIntentListener("QuoteStream", async (context) => { + * const channel: PrivateChannel = await fdc3.createPrivateChannel(); + * const symbol = context.id.symbol; + * + * // Called when the remote side adds a context listener + * const addContextListener = channel.onAddContextListener((contextType) => { + * // broadcast price quotes as they come in from our quote feed + * feed.onQuote(symbol, (price) => { + * channel.broadcast({ type: "price", price}); + * }); + * }); + * + * // Stop the feed if the remote side closes + * const disconnectListener = channel.onDisconnect(() => { + * feed.stop(symbol); + * }); + * + * return channel; + * }); * ``` */ addIntentListener(intent: string, handler: IntentHandler): Promise; @@ -293,13 +371,67 @@ export interface DesktopAgent { joinChannel(channelId: string): Promise; /** - * Returns a channel with the given identity. Either stands up a new channel or returns an existing channel. - * It is up to applications to manage how to share knowledge of these custom channels across windows and to manage + * Returns a channel with the given identity. Either stands up a new channel + * or returns an existing channel. It is up to applications to manage how to + * share knowledge of these custom channels across windows and to manage * channel ownership and lifecycle. - * `Error` with a string from the `ChannelError` enumeration. + * + * If the Channel cannot be created, the returned promise MUST be rejected with + * an error string from the `ChannelError` enumeration. + * + * ```javascript + * try { + * const myChannel = await fdc3.getOrCreateChannel("myChannel"); + * const myChannel.addContextListener(null, context => {}); + * } + * catch (err){ + * //app could not register the channel + * } + * ``` */ getOrCreateChannel(channelId: string): Promise; + /** + * Returns a `Channel` with an auto-generated identity that is intended for private communication between applications. Primarily used to create Channels that will be returned to other applications via an `IntentResolution` for a raised intent. + * + * If the `PrivateChannel` cannot be created, the returned promise MUST be rejected with an error string from the `ChannelError` enumeration. + * + * The `PrivateChannel` type is provided to support synchronisation of data transmitted over returned channels, by allowing both parties to listen for events denoting subscription and unsubscription from the returned channel. `PrivateChannels` are only retrievable via raising an intent. + * + * * It is intended that Desktop Agent implementations: + * - SHOULD restrict external apps from listening or publishing on this channel. + * - MUST prevent private channels from being retrieved via fdc3.getOrCreateChannel. + * - MUST provide the `id` value for the channel as required by the Channel interface. + * + * ```javascript + * fdc3.addIntentListener("QuoteStream", async (context) => { + * const channel: PrivateChannel = await fdc3.createPrivateChannel(); + * const symbol = context.id.ticker; + * + * // This gets called when the remote side adds a context listener + * const addContextListener = channel.onAddContextListener((contextType) => { + * // broadcast price quotes as they come in from our quote feed + * feed.onQuote(symbol, (price) => { + * channel.broadcast({ type: "price", price}); + * }); + * }); + * + * // This gets called when the remote side calls Listener.unsubscribe() + * const unsubscriberListener = channel.onUnsubscribe((contextType) => { + * feed.stop(symbol); + * }); + * + * // This gets called if the remote side closes + * const disconnectListener = channel.onDisconnect(() => { + * feed.stop(symbol); + * }) + * + * return channel; + * }); + * ``` + */ + createPrivateChannel(): Promise; + /** * Optional function that returns the `Channel` object for the current channel membership. In most cases, an application's membership of channels SHOULD be managed via UX provided to the application by the desktop agent, rather than calling this function directly. * @@ -311,12 +443,13 @@ export interface DesktopAgent { * Optional function that removes the app from any channel membership. In most cases, an application's membership of channels SHOULD be managed via UX provided to the application by the desktop agent, rather than calling this function directly. * * Context broadcast and listening through the top-level `fdc3.broadcast` and `fdc3.addContextListener` will be a no-op when the app is not on a channel. + * * ```javascript * //desktop-agent scope context listener * const fdc3Listener = fdc3.addContextListener(null, context => {}); * await fdc3.leaveCurrentChannel(); * //the fdc3Listener will now cease receiving context - * //listening on a specific channel though, will continue to work + * //listening on a specific channel, retrieved via fdc3.getOrCreateChannel(), will continue to work: * redChannel.addContextListener(null, channelListener); * ``` */ diff --git a/src/api/Errors.ts b/src/api/Errors.ts index b83f2e351..3a755e14a 100644 --- a/src/api/Errors.ts +++ b/src/api/Errors.ts @@ -19,8 +19,8 @@ export enum ResolveError { TargetInstanceUnavailable = 'TargetInstanceUnavailable', } -export enum DataError { - NoDataReturned = 'NoDataReturned', +export enum ResultError { + NoResultReturned = 'NoResultReturned', IntentHandlerRejected = 'IntentHandlerRejected', } diff --git a/src/api/IntentResolution.ts b/src/api/IntentResolution.ts index b66a9390a..adab743bc 100644 --- a/src/api/IntentResolution.ts +++ b/src/api/IntentResolution.ts @@ -3,7 +3,7 @@ * Copyright 2019 FINOS FDC3 contributors - see NOTICE file */ -import { Context } from '../context/ContextTypes'; +import { IntentResult } from './Types'; import { AppMetadata } from './AppMetadata'; /** @@ -12,12 +12,14 @@ import { AppMetadata } from './AppMetadata'; * //resolve a "Chain" type intent * let resolution = await agent.raiseIntent("intentName", context); * - * //resolve a "Client-Service" type intent with a data response + * //resolve a "Client-Service" type intent with a data response or a Channel * let resolution = await agent.raiseIntent("intentName", context); * try { * const result = await resolution.getResult(); - * if (result) { - * console.log(`${resolution.source} returned ${JSON.stringify(result)}`); + * if (result && result.broadcast) { + * console.log(`${resolution.source} returned a channel with id ${result.id}`); + * } else if (){ + * console.log(`${resolution.source} returned data: ${JSON.stringify(result)}`); * } else { * console.error(`${resolution.source} didn't return data` * } @@ -26,7 +28,6 @@ import { AppMetadata } from './AppMetadata'; * } * // Use metadata about the resolving app instance to target a further intent * await agent.raiseIntent("intentName", context, resolution.source); - * * ``` */ export interface IntentResolution { @@ -46,11 +47,18 @@ export interface IntentResolution { */ readonly version?: string; /** - * Retrieves a promise that will resolve to data returned by the - * application that resolves the raised intent. The promise MUST - * reject with a string from the `DataError` enumeration if an error - * is thrown by the intent handler, it rejects the returned promise, - * or it does not return a promise. + * Retrieves a promise that will resolve to either `Context` data returned + * by the application that resolves the raised intent or a `Channel` + * established and returned by the app resolving the intent. + * + * A `Channel` returned will often be of the `PrivateChannel` type. The + * client can then `addContextListener()` on that channel to, for example, + * receive a stream of data. + * + * The promise MUST reject with a string from the `ResultError` enumeration + * if an error is thrown by the intent handler, it rejects the returned + * promise, it does not return a promise or the promise resolves to an + * object of an invalid type. */ - getResult(): Promise; + getResult(): Promise; } diff --git a/src/api/Methods.ts b/src/api/Methods.ts index a616f6dee..3aec31c3b 100644 --- a/src/api/Methods.ts +++ b/src/api/Methods.ts @@ -60,12 +60,12 @@ export function open(app: TargetApp, context?: Context): Promise { return rejectIfNoGlobal(() => window.fdc3.open(app, context)); } -export function findIntent(intent: string, context?: Context): Promise { - return rejectIfNoGlobal(() => window.fdc3.findIntent(intent, context)); +export function findIntent(intent: string, context?: Context, resultType?: string): Promise { + return rejectIfNoGlobal(() => window.fdc3.findIntent(intent, context, resultType)); } -export function findIntentsByContext(context: Context): Promise { - return rejectIfNoGlobal(() => window.fdc3.findIntentsByContext(context)); +export function findIntentsByContext(context: Context, resultType?: string): Promise { + return rejectIfNoGlobal(() => window.fdc3.findIntentsByContext(context, resultType)); } export function broadcast(context: Context): Promise { diff --git a/src/api/PrivateChannel.ts b/src/api/PrivateChannel.ts new file mode 100644 index 000000000..2253d7989 --- /dev/null +++ b/src/api/PrivateChannel.ts @@ -0,0 +1,58 @@ +/** + * SPDX-License-Identifier: Apache-2.0 + * Copyright 2021 FINOS FDC3 contributors - see NOTICE file + */ + +import { Listener } from './Listener'; +import { Channel } from './Channel'; + +/** + * Object representing a private context channel, which is intended to support + * secure communication between applications, and extends the Channel interface + * with event handlers which provide information on the connection state of both + * parties, ensuring that desktop agents do not need to queue or retain messages + * that are broadcast before a context listener is added and that applications + * are able to stop broadcasting messages when the other party has disconnected. + * + * It is intended that Desktop Agent implementations: + * - SHOULD restrict external apps from listening or publishing on this channel. + * - MUST prevent private channels from being retrieved via fdc3.getOrCreateChannel. + * - MUST provide the `id` value for the channel as required by the Channel interface. + */ +export interface PrivateChannel extends Channel { + /** + * Adds a listener that will be called each time that the remote app invokes + * addContextListener on this channel. + * + * Desktop Agents MUST call this for each invokation of addContextListener on this + * channel, including those that occurred before this handler was registered + * (to prevent race conditions). + */ + onAddContextListener(handler: (contextType?: string) => void): Listener; + + /** + * Adds a listener that will be called whenever the remote app invokes + * Listener.unsubscribe() on a context listener that it previously added. + * + * Desktop Agents MUST call this when disconnect() is called by the other party, for + * each listner that they had added. + */ + onUnsubscribe(handler: (contextType?: string) => void): Listener; + + /** + * Adds a listener that will be called when the remote app terminates, for example + * when its window is closed or because disconnect was called. This is in addition + * to calls that will be made to an onUnsubscribe listeners. + */ + onDisconnect(handler: () => void): Listener; + + /** + * May be called to indicate that a participant will no longer interact with this channel. + * + * After this function has been called, Desktop Agents SHOULD prevent apps from broadcasting + * on this channel and MUST automatically call Listener.unsubscribe() for each listener that + * they've added (causing any onUnsubscribe handler added by the other party to be called) + * before triggering any onDisconnect handler added by the other party. + */ + disconnect(): void; +} diff --git a/src/api/Types.ts b/src/api/Types.ts index d1ab34bdf..b924529a7 100644 --- a/src/api/Types.ts +++ b/src/api/Types.ts @@ -3,7 +3,7 @@ * Copyright 2019 FINOS FDC3 contributors - see NOTICE file */ -import { AppMetadata } from '..'; +import { AppMetadata, Channel } from '..'; import { Context } from '../context/ContextTypes'; /** @@ -17,13 +17,15 @@ export type TargetApp = string | AppMetadata; * Used when attaching listeners for context broadcasts. */ export type ContextHandler = (context: Context) => void; +/** + * Intents can return results that are either context data objects + * or a reference to a Channel. + */ +export type IntentResult = Context | Channel; /** * Describes a callback that handles a context event and may return a - * promise of a Context object to be returned to the application that - * raised the intent. Any response other than Promise indicates that - * the handler does not return data (which will result in the Desktop - * Agent rejecting the IntentResolution.getData() promise with the - * appropriate error message). + * promise of a Context or Channel object to be returned to the + * application that raised the intent. * Used when attaching listeners for raised intents. */ -export type IntentHandler = (context: Context) => Promise | void; +export type IntentHandler = (context: Context) => Promise | void; diff --git a/src/app-directory/specification/appd.yaml b/src/app-directory/specification/appd.yaml index 65f24b8aa..bece799ed 100644 --- a/src/app-directory/specification/appd.yaml +++ b/src/app-directory/specification/appd.yaml @@ -377,8 +377,15 @@ components: items: type: string description: >- - A comma separated list of context-types that the application's intent(s) can process. + A comma separated list of the types of contexts the intent offered by the application can process, where the first part of the context type is the namespace e.g."fdc3.contact, org.symphony.contact" + resultType: + type: string + description: >- + An optional type for output returned by the application, if any, when resolving this intent. + May indicate a context type by type name (e.g. "fdc3.instrument"), a channel (e.g. "channel") + or a combination that indicates a channel that returns a particular context type + (e.g. "channel"). customConfig: type: object description: >- diff --git a/test/Methods.test.ts b/test/Methods.test.ts index 57ad928a1..3f3082f89 100644 --- a/test/Methods.test.ts +++ b/test/Methods.test.ts @@ -128,14 +128,30 @@ describe('test ES6 module', () => { await findIntent(intent, ContactContext); expect(window.fdc3.findIntent).toHaveBeenCalledTimes(1); - expect(window.fdc3.findIntent).toHaveBeenCalledWith(intent, ContactContext); + expect(window.fdc3.findIntent).toHaveBeenLastCalledWith(intent, ContactContext, undefined); + }); + + test('findIntent should delegate to window.fdc3.findIntent (with additional output type argument)', async () => { + const intent = 'ViewChart'; + + await findIntent(intent, ContactContext, ContextTypes.Contact); + + expect(window.fdc3.findIntent).toHaveBeenCalledTimes(2); + expect(window.fdc3.findIntent).toHaveBeenLastCalledWith(intent, ContactContext, ContextTypes.Contact); }); test('findIntentsByContext should delegate to window.fdc3.findIntentsByContext', async () => { await findIntentsByContext(ContactContext); expect(window.fdc3.findIntentsByContext).toHaveBeenCalledTimes(1); - expect(window.fdc3.findIntentsByContext).toHaveBeenCalledWith(ContactContext); + expect(window.fdc3.findIntentsByContext).toHaveBeenLastCalledWith(ContactContext, undefined); + }); + + test('findIntentsByContext should delegate to window.fdc3.findIntentsByContext (with additional output type argument)', async () => { + await findIntentsByContext(ContactContext, ContextTypes.Contact); + + expect(window.fdc3.findIntentsByContext).toHaveBeenCalledTimes(2); + expect(window.fdc3.findIntentsByContext).toHaveBeenLastCalledWith(ContactContext, ContextTypes.Contact); }); test('broadcast should delegate to window.fdc3.broadcast', async () => { diff --git a/website/sidebars.json b/website/sidebars.json index 9b45062a3..6886419ec 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -18,6 +18,7 @@ "ids": [ "api/ref/DesktopAgent", "api/ref/Channel", + "api/ref/PrivateChannel", "api/ref/Listener", "api/ref/Globals", "api/ref/Types", diff --git a/website/static/schemas/next/app-directory.yaml b/website/static/schemas/next/app-directory.yaml index 65f24b8aa..104f5bca5 100644 --- a/website/static/schemas/next/app-directory.yaml +++ b/website/static/schemas/next/app-directory.yaml @@ -377,8 +377,12 @@ components: items: type: string description: >- - A comma separated list of context-types that the application's intent(s) can process. + A comma separated list of the types of contexts the intent offered by the application can process, where the first part of the context type is the namespace e.g."fdc3.contact, org.symphony.contact" + resultContext: + type: string + description: >- + The type of context return by the application when resolving this intent. E.g. "fdc3.instrument" customConfig: type: object description: >-