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