Commit 12042882 authored by Robert Knight's avatar Robert Knight

Add type information for API call methods.

Add `/** @type {APICall<...>} */` annotations to methods in `APIService`
to specify parameter, body and return types for methods that make API calls.
The existing `APICallFunction` type was renamed to `APICall` for
brevity.

This required adding a few additional casts in other modules to assert
that certain uses of possibly-undefined fields (eg. `annotation.id`) are
safe in a particular context.
parent 37a2a8bd
...@@ -56,7 +56,9 @@ function AnnotationView({ loadAnnotationsService, onLogin }) { ...@@ -56,7 +56,9 @@ function AnnotationView({ loadAnnotationsService, onLogin }) {
// Make the full thread of annotations visible. By default replies are // Make the full thread of annotations visible. By default replies are
// not shown until the user expands the thread. // not shown until the user expands the thread.
annots.forEach(annot => store.setExpanded(annot.id, true)); annots.forEach(annot =>
store.setExpanded(/** @type {string} */ (annot.id), true)
);
// FIXME - This should show a visual indication of which reply the // FIXME - This should show a visual indication of which reply the
// annotation ID in the URL refers to. That isn't currently working. // annotation ID in the URL refers to. That isn't currently working.
......
...@@ -34,10 +34,11 @@ function ModerationBanner({ annotation, api, toastMessenger }) { ...@@ -34,10 +34,11 @@ function ModerationBanner({ annotation, api, toastMessenger }) {
* Hide an annotation from non-moderator users. * Hide an annotation from non-moderator users.
*/ */
const hideAnnotation = () => { const hideAnnotation = () => {
const id = /** @type {string} */ (annotation.id);
api.annotation api.annotation
.hide({ id: annotation.id }) .hide({ id })
.then(() => { .then(() => {
store.hideAnnotation(annotation.id); store.hideAnnotation(id);
}) })
.catch(() => { .catch(() => {
toastMessenger.error('Failed to hide annotation'); toastMessenger.error('Failed to hide annotation');
...@@ -48,10 +49,11 @@ function ModerationBanner({ annotation, api, toastMessenger }) { ...@@ -48,10 +49,11 @@ function ModerationBanner({ annotation, api, toastMessenger }) {
* Un-hide an annotation from non-moderator users. * Un-hide an annotation from non-moderator users.
*/ */
const unhideAnnotation = () => { const unhideAnnotation = () => {
const id = /** @type {string} */ (annotation.id);
api.annotation api.annotation
.unhide({ id: annotation.id }) .unhide({ id })
.then(() => { .then(() => {
store.unhideAnnotation(annotation.id); store.unhideAnnotation(id);
}) })
.catch(() => { .catch(() => {
toastMessenger.error('Failed to unhide annotation'); toastMessenger.error('Failed to unhide annotation');
......
...@@ -3,7 +3,10 @@ import * as queryString from 'query-string'; ...@@ -3,7 +3,10 @@ import * as queryString from 'query-string';
import { replaceURLParams } from '../util/url'; import { replaceURLParams } from '../util/url';
/** /**
* @typedef {import('../../types/api').Annotation} Annotation
* @typedef {import('../../types/api').Group} Group
* @typedef {import('../../types/api').RouteMap} RouteMap * @typedef {import('../../types/api').RouteMap} RouteMap
* @typedef {import('../../types/api').Profile} Profile
*/ */
/** /**
...@@ -39,8 +42,9 @@ function stripInternalProperties(obj) { ...@@ -39,8 +42,9 @@ function stripInternalProperties(obj) {
} }
/** /**
* @template {object} Body
* @typedef APIResponse * @typedef APIResponse
* @prop {any} data - * @prop {Body} data -
* The JSON response from the API call, unless this call returned a * The JSON response from the API call, unless this call returned a
* "204 No Content" status. * "204 No Content" status.
* @prop {string|null} token - The access token that was used to make the call * @prop {string|null} token - The access token that was used to make the call
...@@ -59,11 +63,14 @@ function stripInternalProperties(obj) { ...@@ -59,11 +63,14 @@ function stripInternalProperties(obj) {
/** /**
* Function which makes an API request. * Function which makes an API request.
* *
* @callback APICallFunction * @template {Record<string, any>} Params
* @param {Record<string, any>} params - A map of URL and query string parameters to include with the request. * @template {object} Body
* @param {object} [data] - The body of the request. * @template Result
* @callback APICall
* @param {Params} params - A map of URL and query string parameters to include with the request.
* @param {Body} [data] - The body of the request.
* @param {APICallOptions} [options] * @param {APICallOptions} [options]
* @return {Promise<any|APIResponse>} * @return {Promise<Result>}
*/ */
/** /**
...@@ -93,7 +100,7 @@ function get(object, path) { ...@@ -93,7 +100,7 @@ function get(object, path) {
* @param {Promise<RouteMap>} links - API route data from API index endpoint (`/api/`) * @param {Promise<RouteMap>} links - API route data from API index endpoint (`/api/`)
* @param {string} route - The dotted path of the named API route (eg. `annotation.create`) * @param {string} route - The dotted path of the named API route (eg. `annotation.create`)
* @param {APIMethodCallbacks} callbacks * @param {APIMethodCallbacks} callbacks
* @return {APICallFunction} * @return {APICall<Record<string, any>, object, object>}
*/ */
function createAPICall( function createAPICall(
links, links,
...@@ -182,8 +189,8 @@ function createAPICall( ...@@ -182,8 +189,8 @@ function createAPICall(
* API client for the Hypothesis REST API. * API client for the Hypothesis REST API.
* *
* Returns an object that with keys that match the routes in * Returns an object that with keys that match the routes in
* the Hypothesis API (see http://h.readthedocs.io/en/latest/api/). See * the Hypothesis API (see http://h.readthedocs.io/en/latest/api/).
* `APICallFunction` for the syntax of API calls. For example: * @see APICall for the syntax of API calls. For example:
* *
* ``` * ```
* api.annotations.update({ id: '1234' }, annotation).then(ann => { * api.annotations.update({ id: '1234' }, annotation).then(ann => {
...@@ -226,30 +233,70 @@ export class APIService { ...@@ -226,30 +233,70 @@ export class APIService {
onRequestFinished: store.apiRequestFinished, onRequestFinished: store.apiRequestFinished,
}); });
// Define available API calls.
//
// The type syntax is APICall<Parameters, Body, Result>, where `void` means
// no body / empty response.
/**
* @typedef AnnotationSearchResult
* @prop {Annotation[]} rows
* @prop {Annotation[]} replies
* @prop {number} total
*/
/** @type {APICall<object, void, AnnotationSearchResult>} */
this.search = apiCall('search'); this.search = apiCall('search');
this.annotation = { this.annotation = {
/** @type {APICall<{}, Partial<Annotation>, Annotation>} */
create: apiCall('annotation.create'), create: apiCall('annotation.create'),
/** @type {APICall<{ id: string }, void, void>} */
delete: apiCall('annotation.delete'), delete: apiCall('annotation.delete'),
/** @type {APICall<{ id: string }, void, Annotation>} */
get: apiCall('annotation.read'), get: apiCall('annotation.read'),
/** @type {APICall<{ id: string }, Partial<Annotation>, Annotation>} */
update: apiCall('annotation.update'), update: apiCall('annotation.update'),
/** @type {APICall<{ id: string }, void, void>} */
flag: apiCall('annotation.flag'), flag: apiCall('annotation.flag'),
/** @type {APICall<{ id: string }, void, void>} */
hide: apiCall('annotation.hide'), hide: apiCall('annotation.hide'),
/** @type {APICall<{ id: string }, void, void>} */
unhide: apiCall('annotation.unhide'), unhide: apiCall('annotation.unhide'),
}; };
this.group = { this.group = {
member: { member: {
/** @type {APICall<{ pubid: string, userid: string }, void, void>} */
delete: apiCall('group.member.delete'), delete: apiCall('group.member.delete'),
}, },
/** @type {APICall<{ id: string, expand: string[] }, void, Group>} */
read: apiCall('group.read'), read: apiCall('group.read'),
}; };
/**
* @typedef ListGroupParams
* @prop {string} [authority]
* @prop {string} [document_uri]
* @prop {string[]} [expand]
*/
this.groups = { this.groups = {
/** @type {APICall<ListGroupParams, void, Group[]>} */
list: apiCall('groups.read'), list: apiCall('groups.read'),
}; };
this.profile = { this.profile = {
groups: { groups: {
/** @type {APICall<{ expand: string[] }, void, Group[]>} */
read: apiCall('profile.groups.read'), read: apiCall('profile.groups.read'),
}, },
/** @type {APICall<{ authority?: string }, void, Profile>} */
read: apiCall('profile.read'), read: apiCall('profile.read'),
/** @type {APICall<{}, Partial<Profile>, Profile>} */
update: apiCall('profile.update'), update: apiCall('profile.update'),
}; };
} }
......
...@@ -378,8 +378,9 @@ export class GroupsService { ...@@ -378,8 +378,9 @@ export class GroupsService {
userGroups.find(g => g.id === id || g.groupid === id) || userGroups.find(g => g.id === id || g.groupid === id) ||
tryFetchGroup(id); tryFetchGroup(id);
const groups = (await Promise.all(groupIds.map(getGroup))).filter( const groupResults = await Promise.all(groupIds.map(getGroup));
g => g !== null const groups = /** @type {Group[]} */ (
groupResults.filter(g => g !== null)
); );
// Optional direct linked group id. This is used in the Notebook context. // Optional direct linked group id. This is used in the Notebook context.
......
...@@ -180,7 +180,7 @@ export class LoadAnnotationsService { ...@@ -180,7 +180,7 @@ export class LoadAnnotationsService {
// fetch the top-level annotation // fetch the top-level annotation
if (isReply(annotation)) { if (isReply(annotation)) {
annotation = await this._api.annotation.get({ annotation = await this._api.annotation.get({
id: annotation.references[0], id: /** @type {string[]} */ (annotation.references)[0],
}); });
} }
...@@ -198,9 +198,10 @@ export class LoadAnnotationsService { ...@@ -198,9 +198,10 @@ export class LoadAnnotationsService {
// configure the connection to the real-time update service to send us // configure the connection to the real-time update service to send us
// updates to any of the annotations in the thread. // updates to any of the annotations in the thread.
if (!isReply(annotation)) { if (!isReply(annotation)) {
const id = /** @type {string} */ (annotation.id);
this._streamFilter this._streamFilter
.addClause('/references', 'one_of', annotation.id, true) .addClause('/references', 'one_of', id, true)
.addClause('/id', 'equals', annotation.id, true); .addClause('/id', 'equals', id, true);
this._streamer.setConfig('filter', { this._streamer.setConfig('filter', {
filter: this._streamFilter.getFilter(), filter: this._streamFilter.getFilter(),
}); });
......
...@@ -142,6 +142,7 @@ ...@@ -142,6 +142,7 @@
* *
* @typedef Group * @typedef Group
* @prop {string} id * @prop {string} id
* @prop {string} groupid
* @prop {'private'|'open'} type * @prop {'private'|'open'} type
* @prop {Organization} organization - nb. This field is nullable in the API, but * @prop {Organization} organization - nb. This field is nullable in the API, but
* we assign a default organization on the client. * we assign a default organization on the client.
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment