Commit 15d64695 authored by Alejandro Celaya's avatar Alejandro Celaya Committed by Alejandro Celaya

Migrate APIService to TS

parent 27ce4cd3
import type {
Annotation,
Group,
RouteMap,
RouteMetadata,
Profile,
} from '../../types/api';
import type { SidebarStore } from '../store';
import { fetchJSON } from '../util/fetch'; import { fetchJSON } from '../util/fetch';
import { replaceURLParams } from '../util/url'; import { replaceURLParams } from '../util/url';
import type { APIRoutesService } from './api-routes';
/** import type { AuthService } from './auth';
* @typedef {import('../../types/api').Annotation} Annotation
* @typedef {import('../../types/api').Group} Group
* @typedef {import('../../types/api').RouteMap} RouteMap
* @typedef {import('../../types/api').RouteMetadata} RouteMetadata
* @typedef {import('../../types/api').Profile} Profile
*/
/** /**
* Return a shallow clone of `obj` with all client-only properties removed. * Return a shallow clone of `obj` with all client-only properties removed.
* Client-only properties are marked by a '$' prefix. * Client-only properties are marked by a '$' prefix.
*
* @param {Record<string, unknown>} obj
*/ */
function stripInternalProperties(obj) { function stripInternalProperties(
/** @type {Record<string, unknown>} */ obj: Record<string, unknown>
const result = {}; ): Record<string, unknown> {
for (let [key, value] of Object.entries(obj)) { const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (!key.startsWith('$')) { if (!key.startsWith('$')) {
result[key] = value; result[key] = value;
} }
...@@ -26,66 +27,59 @@ function stripInternalProperties(obj) { ...@@ -26,66 +27,59 @@ function stripInternalProperties(obj) {
return result; return result;
} }
/**
* @template {object} Body
* @typedef APIResponse
* @prop {Body} data -
* The JSON response from the API call, unless this call returned a
* "204 No Content" status.
* @prop {string|null} token - The access token that was used to make the call
* or `null` if unauthenticated.
*/
/** /**
* Types of value that can be passed as a parameter to API calls. * Types of value that can be passed as a parameter to API calls.
*
* @typedef {string|number|boolean} Param
*/ */
type Param = string | number | boolean;
/** /**
* Function which makes an API request. * Function which makes an API request.
* * @param params - A map of URL and query string parameters to include with the request.
* @template {Record<string, Param|Param[]>} [Params={}] * @param data - The body of the request.
* @template [Body=void]
* @template [Result=void]
* @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.
* @return {Promise<Result>}
*/ */
type APICall<
Params = Record<string, Param | Param[]>,
Body = void,
Result = void
> = (params: Params, data?: Body) => Promise<Result>;
/** /**
* Callbacks invoked at various points during an API call to get an access token etc. * Callbacks invoked at various points during an API call to get an access token etc.
* */
* @typedef APIMethodCallbacks type APIMethodCallbacks = {
* @prop {() => Promise<string|null>} getAccessToken - /** Function which acquires a valid access token for making an API request */
* Function which acquires a valid access token for making an API request. getAccessToken: () => Promise<string | null>;
* @prop {() => string|null} getClientId -
/**
* Function that returns a per-session client ID to include with the request * Function that returns a per-session client ID to include with the request
* or `null`. * or `null`.
* @prop {() => void} onRequestStarted - Callback invoked when the API request starts.
* @prop {() => void} onRequestFinished - Callback invoked when the API request finishes.
*/ */
getClientId: () => string | null;
/** /** Callback invoked when the API request starts */
* @param {RouteMap|RouteMetadata} link onRequestStarted: () => void;
* @return {link is RouteMetadata} /** Callback invoked when the API request finishes */
*/ onRequestFinished: () => void;
function isRouteMetadata(link) { };
function isRouteMetadata(
link: RouteMap | RouteMetadata
): link is RouteMetadata {
return 'url' in link; return 'url' in link;
} }
/** /**
* Lookup metadata for an API route in the result of an `/api/` response. * Lookup metadata for an API route in the result of an `/api/` response.
* *
* @param {RouteMap} routeMap * @param route - Dot-separated path of route in `routeMap`
* @param {string} route - Dot-separated path of route in `routeMap`
*/ */
function findRouteMetadata(routeMap, route) { function findRouteMetadata(
/** @type {RouteMap} */ routeMap: RouteMap,
route: string
): RouteMetadata | null {
let cursor = routeMap; let cursor = routeMap;
const pathSegments = route.split('.'); const pathSegments = route.split('.');
for (let [index, segment] of pathSegments.entries()) { for (const [index, segment] of pathSegments.entries()) {
const nextCursor = cursor[segment]; const nextCursor = cursor[segment];
if (!nextCursor || isRouteMetadata(nextCursor)) { if (!nextCursor || isRouteMetadata(nextCursor)) {
if (nextCursor && index === pathSegments.length - 1) { if (nextCursor && index === pathSegments.length - 1) {
...@@ -104,18 +98,22 @@ function findRouteMetadata(routeMap, route) { ...@@ -104,18 +98,22 @@ function findRouteMetadata(routeMap, route) {
/** /**
* Creates a function that will make an API call to a named route. * Creates a function that will make an API call to a named route.
* *
* @param {Promise<RouteMap>} links - API route data from API index endpoint (`/api/`) * @param links - API route data from API index endpoint (`/api/`)
* @param {string} route - The dotted path of the named API route (eg. `annotation.create`) * @param route - The dotted path of the named API route (eg. `annotation.create`)
* @param {APIMethodCallbacks} callbacks * @return Function that makes an API call. The returned `APICall` has generic
* @return {APICall<Record<string, any>, Record<string, any>|void, unknown>} - Function that makes * parameter, body and return types.
* an API call. The returned `APICall` has generic parameter, body and return types.
* This can be cast to an `APICall` with more specific types. * This can be cast to an `APICall` with more specific types.
*/ */
function createAPICall( function createAPICall(
links, links: Promise<RouteMap>,
route, route: string,
{ getAccessToken, getClientId, onRequestStarted, onRequestFinished } {
) { getAccessToken,
getClientId,
onRequestStarted,
onRequestFinished,
}: APIMethodCallbacks
): APICall<Record<string, any>, Record<string, any> | void, unknown> {
return async (params, data) => { return async (params, data) => {
onRequestStarted(); onRequestStarted();
try { try {
...@@ -125,8 +123,7 @@ function createAPICall( ...@@ -125,8 +123,7 @@ function createAPICall(
throw new Error(`Missing API route: ${route}`); throw new Error(`Missing API route: ${route}`);
} }
/** @type {Record<string, string>} */ const headers: Record<string, string> = {
const headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Hypothesis-Client-Version': '__VERSION__', 'Hypothesis-Client-Version': '__VERSION__',
}; };
...@@ -146,11 +143,9 @@ function createAPICall( ...@@ -146,11 +143,9 @@ function createAPICall(
); );
const apiURL = new URL(url); const apiURL = new URL(url);
for (let [key, value] of Object.entries(queryParams)) { for (const [key, value] of Object.entries(queryParams)) {
if (!Array.isArray(value)) { const values = Array.isArray(value) ? value : [value];
value = [value]; for (const item of values) {
}
for (let item of value) {
// eslint-disable-next-line eqeqeq // eslint-disable-next-line eqeqeq
if (item == null) { if (item == null) {
// Skip all parameters with nullish values. // Skip all parameters with nullish values.
...@@ -175,6 +170,22 @@ function createAPICall( ...@@ -175,6 +170,22 @@ function createAPICall(
}; };
} }
type AnnotationSearchResult = {
rows: Annotation[];
replies: Annotation[];
total: number;
};
type IDParam = {
id: string;
};
type ListGroupParams = {
authority?: string;
document_uri?: string;
expand?: string[];
};
/** /**
* API client for the Hypothesis REST API. * API client for the Hypothesis REST API.
* *
...@@ -196,26 +207,49 @@ function createAPICall( ...@@ -196,26 +207,49 @@ function createAPICall(
*/ */
// @inject // @inject
export class APIService { export class APIService {
/**
* @param {import('./api-routes').APIRoutesService} apiRoutes
* @param {import('./auth').AuthService} auth
* @param {import('../store').SidebarStore} store
*/
constructor(apiRoutes, auth, store) {
const links = apiRoutes.routes();
/** /**
* Client session identifier included with requests. Used by the backend * Client session identifier included with requests. Used by the backend
* to associate API requests with WebSocket connections from the same client. * to associate API requests with WebSocket connections from the same client.
*
* @type {string|null}
*/ */
private _clientId: string | null;
search: APICall<Record<string, unknown>, void, AnnotationSearchResult>;
annotation: {
create: APICall<Record<string, unknown>, Partial<Annotation>, Annotation>;
delete: APICall<IDParam>;
get: APICall<IDParam, void, Annotation>;
update: APICall<IDParam, Partial<Annotation>, Annotation>;
flag: APICall<IDParam>;
hide: APICall<IDParam>;
unhide: APICall<IDParam>;
};
group: {
member: {
delete: APICall<{ pubid: string; userid: string }>;
};
read: APICall<{ id: string; expand: string[] }, void, Group>;
};
groups: {
list: APICall<ListGroupParams, void, Group[]>;
};
profile: {
groups: {
read: APICall<{ expand: string[] }, void, Group[]>;
};
read: APICall<{ authority?: string }, void, Profile>;
update: APICall<Record<string, unknown>, Partial<Profile>, Profile>;
};
constructor(
apiRoutes: APIRoutesService,
auth: AuthService,
store: SidebarStore
) {
this._clientId = null; this._clientId = null;
const links = apiRoutes.routes();
const getClientId = () => this._clientId; const getClientId = () => this._clientId;
const apiCall = (route: string) =>
/** @param {string} route */
const apiCall = route =>
createAPICall(links, route, { createAPICall(links, route, {
getAccessToken: () => auth.getAccessToken(), getAccessToken: () => auth.getAccessToken(),
getClientId, getClientId,
...@@ -228,68 +262,62 @@ export class APIService { ...@@ -228,68 +262,62 @@ export class APIService {
// The type syntax is APICall<Parameters, Body, Result>, where `void` means // The type syntax is APICall<Parameters, Body, Result>, where `void` means
// no body / empty response. // no body / empty response.
/** this.search = apiCall('search') as APICall<
* @typedef AnnotationSearchResult Record<string, unknown>,
* @prop {Annotation[]} rows void,
* @prop {Annotation[]} replies AnnotationSearchResult
* @prop {number} total >;
*/
/** @typedef {{ id: string }} IDParam */
this.search = /** @type {APICall<{}, void, AnnotationSearchResult>} */ (
apiCall('search')
);
this.annotation = { this.annotation = {
create: /** @type {APICall<{}, Partial<Annotation>, Annotation>} */ ( create: apiCall('annotation.create') as APICall<
apiCall('annotation.create') Record<string, unknown>,
), Partial<Annotation>,
delete: /** @type {APICall<IDParam>} */ (apiCall('annotation.delete')), Annotation
get: /** @type {APICall<IDParam, void, Annotation>} */ ( >,
apiCall('annotation.read') delete: apiCall('annotation.delete') as APICall<IDParam>,
), get: apiCall('annotation.read') as APICall<IDParam, void, Annotation>,
update: /** @type {APICall<IDParam, Partial<Annotation>, Annotation>} */ ( update: apiCall('annotation.update') as APICall<
apiCall('annotation.update') IDParam,
), Partial<Annotation>,
flag: /** @type {APICall<IDParam>} */ (apiCall('annotation.flag')), Annotation
hide: /** @type {APICall<IDParam>} */ (apiCall('annotation.hide')), >,
unhide: /** @type {APICall<IDParam>} */ (apiCall('annotation.unhide')), flag: apiCall('annotation.flag') as APICall<IDParam>,
hide: apiCall('annotation.hide') as APICall<IDParam>,
unhide: apiCall('annotation.unhide') as APICall<IDParam>,
}; };
this.group = { this.group = {
member: { member: {
delete: /** @type {APICall<{ pubid: string, userid: string }>} */ ( delete: apiCall('group.member.delete') as APICall<{
apiCall('group.member.delete') pubid: string;
), userid: string;
}>,
}, },
read: /** @type {APICall<{ id: string, expand: string[] }, void, Group>} */ ( read: apiCall('group.read') as APICall<
apiCall('group.read') { id: string; expand: string[] },
), void,
Group
>,
}; };
/**
* @typedef ListGroupParams
* @prop {string} [authority]
* @prop {string} [document_uri]
* @prop {string[]} [expand]
*/
this.groups = { this.groups = {
list: /** @type {APICall<ListGroupParams, void, Group[]>} */ ( list: apiCall('groups.read') as APICall<ListGroupParams, void, Group[]>,
apiCall('groups.read')
),
}; };
this.profile = { this.profile = {
groups: { groups: {
read: /** @type {APICall<{ expand: string[] }, void, Group[]>} */ ( read: apiCall('profile.groups.read') as APICall<
apiCall('profile.groups.read') { expand: string[] },
), void,
Group[]
>,
}, },
read: /** @type {APICall<{ authority?: string }, void, Profile>} */ ( read: apiCall('profile.read') as APICall<
apiCall('profile.read') { authority?: string },
), void,
update: /** @type {APICall<{}, Partial<Profile>, Profile>} */ ( Profile
apiCall('profile.update') >,
), update: apiCall('profile.update') as APICall<
Record<string, unknown>,
Partial<Profile>,
Profile
>,
}; };
} }
...@@ -299,10 +327,8 @@ export class APIService { ...@@ -299,10 +327,8 @@ export class APIService {
* This is a per-session unique ID which the client sends with REST API * This is a per-session unique ID which the client sends with REST API
* requests and in the configuration for the real-time API to prevent the * requests and in the configuration for the real-time API to prevent the
* client from receiving real-time notifications about its own actions. * client from receiving real-time notifications about its own actions.
*
* @param {string} clientId
*/ */
setClientId(clientId) { setClientId(clientId: string) {
this._clientId = clientId; this._clientId = clientId;
} }
} }
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