Skip to content

Commit

Permalink
Pass HTTP status code & errcode from CS-API errors (#100)
Browse files Browse the repository at this point in the history
* Pass HTTP status code & errcode from CS-API errors

* (De)serialize error response details

Allow client widget drivers to serialize Matrix API error responses into
JSON to be received by the requesting widget.

* Override name property of WidgetApiResponseError

* Disable babel's no-invalid-this rule

because Typescript has its own version of that rule

* Increase test coverage

Mock client-side responses to test deserializing them on the widget side

* Increase test coverage some more

* Accept more than just Matrix API error details

As long as the error details payload is extensible, let drivers put more
data in them than just the key for Matrix API error responses.

* Don't make error data payload extensible

as this makes it too easy for drivers to put data in the wrong section.

Still define the payload type as an interface so that it can be
extended in a future version of the API.

Also don't use a subfield now that non-extensibility makes the format of
the details fields unambiguous.

* Set some missing fields in test

* Test sendToDevice in ClientWidgetApi

* Test navigation in ClientWidgetApi

* Add missing license year
  • Loading branch information
AndrewFerr authored Nov 8, 2024
1 parent 5d1f971 commit e62698f
Show file tree
Hide file tree
Showing 9 changed files with 1,622 additions and 232 deletions.
3 changes: 3 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ module.exports = {
"files": ["src/**/*.ts", "test/**/*.ts"],
"extends": ["matrix-org/ts"],
"rules": {
// TypeScript has its own version of this
"babel/no-invalid-this": "off",

"quotes": "off",
},
}],
Expand Down
54 changes: 23 additions & 31 deletions src/ClientWidgetApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,15 +330,13 @@ export class ClientWidgetApi extends EventEmitter {
});
}

const onErr = (e: any) => {
const onErr = (e: unknown) => {
console.error("[ClientWidgetApi] Failed to handle navigation: ", e);
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: {message: "Error handling navigation"},
});
this.handleDriverError(e, request, "Error handling navigation");
};

try {
this.driver.navigate(request.data.uri.toString()).catch(e => onErr(e)).then(() => {
this.driver.navigate(request.data.uri.toString()).catch((e: unknown) => onErr(e)).then(() => {
return this.transport.reply<IWidgetApiAcknowledgeResponseData>(request, {});
});
} catch (e) {
Expand Down Expand Up @@ -554,11 +552,9 @@ export class ClientWidgetApi extends EventEmitter {
delay_id: sentEvent.delayId,
}),
});
}).catch(e => {
}).catch((e: unknown) => {
console.error("error sending event: ", e);
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: {message: "Error sending event"},
});
this.handleDriverError(e, request, "Error sending event");
});
}

Expand All @@ -581,11 +577,9 @@ export class ClientWidgetApi extends EventEmitter {
case UpdateDelayedEventAction.Send:
this.driver.updateDelayedEvent(request.data.delay_id, request.data.action).then(() => {
return this.transport.reply<IWidgetApiAcknowledgeResponseData>(request, {});
}).catch(e => {
}).catch((e: unknown) => {
console.error("error updating delayed event: ", e);
return this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: {message: "Error updating delayed event"},
});
this.handleDriverError(e, request, "Error updating delayed event");
});
break;
default:
Expand Down Expand Up @@ -618,9 +612,7 @@ export class ClientWidgetApi extends EventEmitter {
await this.transport.reply<ISendToDeviceFromWidgetResponseData>(request, {});
} catch (e) {
console.error("error sending to-device event", e);
await this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: {message: "Error sending event"},
});
this.handleDriverError(e, request, "Error sending event");
}
}
}
Expand Down Expand Up @@ -735,9 +727,7 @@ export class ClientWidgetApi extends EventEmitter {
);
} catch (e) {
console.error("error getting the relations", e);
await this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Unexpected error while reading relations" },
});
this.handleDriverError(e, request, "Unexpected error while reading relations");
}
}

Expand Down Expand Up @@ -778,9 +768,7 @@ export class ClientWidgetApi extends EventEmitter {
);
} catch (e) {
console.error("error searching in the user directory", e);
await this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Unexpected error while searching in the user directory" },
});
this.handleDriverError(e, request, "Unexpected error while searching in the user directory");
}
}

Expand All @@ -800,9 +788,7 @@ export class ClientWidgetApi extends EventEmitter {
);
} catch (e) {
console.error("error while getting the media configuration", e);
await this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Unexpected error while getting the media configuration" },
});
this.handleDriverError(e, request, "Unexpected error while getting the media configuration");
}
}

Expand All @@ -822,9 +808,7 @@ export class ClientWidgetApi extends EventEmitter {
);
} catch (e) {
console.error("error while uploading a file", e);
await this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Unexpected error while uploading a file" },
});
this.handleDriverError(e, request, "Unexpected error while uploading a file");
}
}

Expand All @@ -844,12 +828,20 @@ export class ClientWidgetApi extends EventEmitter {
);
} catch (e) {
console.error("error while downloading a file", e);
this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: { message: "Unexpected error while downloading a file" },
});
this.handleDriverError(e, request, "Unexpected error while downloading a file");
}
}

private handleDriverError(e: unknown, request: IWidgetApiRequest, message: string) {
const data = this.driver.processError(e);
this.transport.reply<IWidgetApiErrorResponseData>(request, {
error: {
message,
...data,
},
});
}

private handleMessage(ev: CustomEvent<IWidgetApiRequest>) {
if (this.isStopped) return;
const actionEv = new CustomEvent(`action:${ev.detail.action}`, {
Expand Down
15 changes: 14 additions & 1 deletion src/WidgetApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
import { ITransport } from "./transport/ITransport";
import { PostmessageTransport } from "./transport/PostmessageTransport";
import { WidgetApiFromWidgetAction, WidgetApiToWidgetAction } from "./interfaces/WidgetApiAction";
import { IWidgetApiErrorResponseData } from "./interfaces/IWidgetApiErrorResponse";
import { IWidgetApiErrorResponseData, IWidgetApiErrorResponseDataDetails } from "./interfaces/IWidgetApiErrorResponse";
import { IStickerActionRequestData } from "./interfaces/StickerAction";
import { IStickyActionRequestData, IStickyActionResponseData } from "./interfaces/StickyAction";
import {
Expand Down Expand Up @@ -95,6 +95,19 @@ import {
UpdateDelayedEventAction,
} from "./interfaces/UpdateDelayedEventAction";

export class WidgetApiResponseError extends Error {
static {
this.prototype.name = this.name;
}

public constructor(
message: string,
public readonly data: IWidgetApiErrorResponseDataDetails,
) {
super(message);
}
}

/**
* API handler for widgets. This raises events for each action
* received as `action:${action}` (eg: "action:screenshot").
Expand Down
11 changes: 11 additions & 0 deletions src/driver/WidgetDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
IRoomEvent,
IRoomAccountData,
ITurnServer,
IWidgetApiErrorResponseDataDetails,
UpdateDelayedEventAction,
} from "..";

Expand Down Expand Up @@ -358,4 +359,14 @@ export abstract class WidgetDriver {
): Promise<{ file: XMLHttpRequestBodyInit }> {
throw new Error("Download file is not implemented");
}

/**
* Expresses an error thrown by this driver in a format compatible with the Widget API.
* @param error The error to handle.
* @returns The error expressed as a {@link IWidgetApiErrorResponseDataDetails},
* or undefined if it cannot be expressed as one.
*/
public processError(error: unknown): IWidgetApiErrorResponseDataDetails | undefined {
return undefined;
}
}
38 changes: 30 additions & 8 deletions src/interfaces/IWidgetApiErrorResponse.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
* Copyright 2020 - 2024 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -16,20 +16,42 @@

import { IWidgetApiResponse, IWidgetApiResponseData } from "./IWidgetApiResponse";

/**
* The format of errors returned by Matrix API requests
* made by a WidgetDriver.
*/
export interface IMatrixApiError {
/** The HTTP status code of the associated request. */
http_status: number; // eslint-disable-line camelcase
/** Any HTTP response headers that are relevant to the error. */
http_headers: {[name: string]: string}; // eslint-disable-line camelcase
/** The URL of the failed request. */
url: string;
/** @see {@link https://spec.matrix.org/latest/client-server-api/#standard-error-response} */
response: {
errcode: string;
error: string;
} & IWidgetApiResponseData; // extensible
}

export interface IWidgetApiErrorResponseDataDetails {
/** Set if the error came from a Matrix API request made by a widget driver */
matrix_api_error?: IMatrixApiError; // eslint-disable-line camelcase
}

export interface IWidgetApiErrorResponseData extends IWidgetApiResponseData {
error: {
/** A user-friendly string describing the error */
message: string;
};
} & IWidgetApiErrorResponseDataDetails;
}

export interface IWidgetApiErrorResponse extends IWidgetApiResponse {
response: IWidgetApiErrorResponseData;
}

export function isErrorResponse(responseData: IWidgetApiResponseData): boolean {
if ("error" in responseData) {
const err = <IWidgetApiErrorResponseData>responseData;
return !!err.error.message;
}
return false;
export function isErrorResponse(responseData: IWidgetApiResponseData): responseData is IWidgetApiErrorResponseData {
const error = responseData.error;
return typeof error === "object" && error !== null &&
"message" in error && typeof error.message === "string";
}
20 changes: 11 additions & 9 deletions src/transport/ITransport.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
* Copyright 2020 - 2024 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -71,11 +71,12 @@ export interface ITransport extends EventEmitter {

/**
* Sends a request to the remote end.
* @param {WidgetApiAction} action The action to send.
* @param {IWidgetApiRequestData} data The request data.
* @returns {Promise<IWidgetApiResponseData>} A promise which resolves
* to the remote end's response, or throws with an Error if the request
* failed.
* @param action The action to send.
* @param data The request data.
* @returns A promise which resolves to the remote end's response.
* @throws {Error} if the request failed with a generic error.
* @throws {WidgetApiResponseError} if the request failed with error details
* that can be communicated to the Widget API.
*/
send<T extends IWidgetApiRequestData, R extends IWidgetApiResponseData = IWidgetApiAcknowledgeResponseData>(
action: WidgetApiAction,
Expand All @@ -88,9 +89,10 @@ export interface ITransport extends EventEmitter {
* data.
* @param {WidgetApiAction} action The action to send.
* @param {IWidgetApiRequestData} data The request data.
* @returns {Promise<IWidgetApiResponseData>} A promise which resolves
* to the remote end's response, or throws with an Error if the request
* failed.
* @returns {Promise<IWidgetApiResponseData>} A promise which resolves to the remote end's response
* @throws {Error} if the request failed with a generic error.
* @throws {WidgetApiResponseError} if the request failed with error details
* that can be communicated to the Widget API.
*/
sendComplete<T extends IWidgetApiRequestData, R extends IWidgetApiResponse>(action: WidgetApiAction, data: T)
: Promise<R>;
Expand Down
8 changes: 4 additions & 4 deletions src/transport/PostmessageTransport.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Matrix.org Foundation C.I.C.
* Copyright 2020 - 2024 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -19,11 +19,11 @@ import { ITransport } from "./ITransport";
import {
invertedDirection,
isErrorResponse,
IWidgetApiErrorResponseData,
IWidgetApiRequest,
IWidgetApiRequestData,
IWidgetApiResponse,
IWidgetApiResponseData,
WidgetApiResponseError,
WidgetApiAction,
WidgetApiDirection,
WidgetApiToWidgetAction,
Expand Down Expand Up @@ -194,8 +194,8 @@ export class PostmessageTransport extends EventEmitter implements ITransport {
if (!req) return; // response to an unknown request

if (isErrorResponse(response.response)) {
const err = <IWidgetApiErrorResponseData>response.response;
req.reject(new Error(err.error.message));
const {message, ...data} = response.response.error;
req.reject(new WidgetApiResponseError(message, data));
} else {
req.resolve(response);
}
Expand Down
Loading

0 comments on commit e62698f

Please sign in to comment.