aboutsummaryrefslogtreecommitdiff
path: root/Timeline/ClientApp/src/app/http
diff options
context:
space:
mode:
Diffstat (limited to 'Timeline/ClientApp/src/app/http')
-rw-r--r--Timeline/ClientApp/src/app/http/common.ts140
-rw-r--r--Timeline/ClientApp/src/app/http/mock/common.ts48
-rw-r--r--Timeline/ClientApp/src/app/http/mock/default-avatar.pngbin0 -> 26442 bytes
-rw-r--r--Timeline/ClientApp/src/app/http/mock/install.ts11
-rw-r--r--Timeline/ClientApp/src/app/http/mock/timeline.ts600
-rw-r--r--Timeline/ClientApp/src/app/http/mock/token.ts53
-rw-r--r--Timeline/ClientApp/src/app/http/mock/user.ts132
-rw-r--r--Timeline/ClientApp/src/app/http/timeline.ts476
-rw-r--r--Timeline/ClientApp/src/app/http/token.ts72
-rw-r--r--Timeline/ClientApp/src/app/http/user.ts131
10 files changed, 1663 insertions, 0 deletions
diff --git a/Timeline/ClientApp/src/app/http/common.ts b/Timeline/ClientApp/src/app/http/common.ts
new file mode 100644
index 00000000..8fb8eb69
--- /dev/null
+++ b/Timeline/ClientApp/src/app/http/common.ts
@@ -0,0 +1,140 @@
+import { AxiosError, AxiosResponse } from 'axios';
+
+export const apiBaseUrl = '/api';
+
+export function base64(blob: Blob): Promise<string> {
+ return new Promise<string>((resolve) => {
+ const reader = new FileReader();
+ reader.onload = function () {
+ resolve((reader.result as string).replace(/^data:.+;base64,/, ''));
+ };
+ reader.readAsDataURL(blob);
+ });
+}
+
+export function extractStatusCode(error: AxiosError): number | null {
+ if (error.isAxiosError) {
+ const code = error?.response?.status;
+ if (typeof code === 'number') {
+ return code;
+ }
+ }
+ return null;
+}
+
+export interface CommonErrorResponse {
+ code: number;
+ message: string;
+}
+
+export function extractErrorCode(
+ error: AxiosError<CommonErrorResponse>
+): number | null {
+ if (error.isAxiosError) {
+ const code = error.response?.data?.code;
+ if (typeof code === 'number') {
+ return code;
+ }
+ }
+ return null;
+}
+
+export class HttpNetworkError extends Error {
+ constructor(public innerError?: AxiosError) {
+ super();
+ }
+}
+
+export class NotModified {}
+
+export interface BlobWithEtag {
+ data: Blob;
+ etag: string;
+}
+
+export function extractResponseData<T>(res: AxiosResponse<T>): T {
+ return res.data;
+}
+
+export function catchIfStatusCodeIs<
+ TResult,
+ TErrorHandlerResult extends TResult | PromiseLike<TResult> | null | undefined
+>(
+ statusCode: number,
+ errorHandler: (error: AxiosError<CommonErrorResponse>) => TErrorHandlerResult
+): (error: AxiosError<CommonErrorResponse>) => TErrorHandlerResult {
+ return (error: AxiosError<CommonErrorResponse>) => {
+ if (extractStatusCode(error) == statusCode) {
+ return errorHandler(error);
+ } else {
+ throw error;
+ }
+ };
+}
+
+export function convertToIfStatusCodeIs<NewError>(
+ statusCode: number,
+ newErrorType: {
+ new (innerError: AxiosError): NewError;
+ }
+): (error: AxiosError<CommonErrorResponse>) => never {
+ return catchIfStatusCodeIs(statusCode, (error) => {
+ throw new newErrorType(error);
+ });
+}
+
+export function catchIfErrorCodeIs<
+ TResult,
+ TErrorHandlerResult extends TResult | PromiseLike<TResult> | null | undefined
+>(
+ errorCode: number,
+ errorHandler: (error: AxiosError<CommonErrorResponse>) => TErrorHandlerResult
+): (error: AxiosError<CommonErrorResponse>) => TErrorHandlerResult {
+ return (error: AxiosError<CommonErrorResponse>) => {
+ if (extractErrorCode(error) == errorCode) {
+ return errorHandler(error);
+ } else {
+ throw error;
+ }
+ };
+}
+export function convertToIfErrorCodeIs<NewError>(
+ errorCode: number,
+ newErrorType: {
+ new (innerError: AxiosError): NewError;
+ }
+): (error: AxiosError<CommonErrorResponse>) => never {
+ return catchIfErrorCodeIs(errorCode, (error) => {
+ throw new newErrorType(error);
+ });
+}
+
+export function convertToNetworkError(
+ error: AxiosError<CommonErrorResponse>
+): never {
+ if (error.isAxiosError && error.response == null) {
+ throw new HttpNetworkError(error);
+ } else {
+ throw error;
+ }
+}
+
+export function convertToBlobWithEtag(res: AxiosResponse<Blob>): BlobWithEtag {
+ return {
+ data: res.data,
+ etag: (res.headers as Record<'etag', string>)['etag'],
+ };
+}
+
+export function convertToBlobWithEtagOrNotModified(
+ res: AxiosResponse<Blob>
+): BlobWithEtag | NotModified {
+ if (res.status === 304) {
+ return new NotModified();
+ } else {
+ return {
+ data: res.data,
+ etag: (res.headers as Record<'etag', string>)['etag'],
+ };
+ }
+}
diff --git a/Timeline/ClientApp/src/app/http/mock/common.ts b/Timeline/ClientApp/src/app/http/mock/common.ts
new file mode 100644
index 00000000..11939c2b
--- /dev/null
+++ b/Timeline/ClientApp/src/app/http/mock/common.ts
@@ -0,0 +1,48 @@
+import localforage from 'localforage';
+import { SHA1 } from 'crypto-js';
+
+import { HttpNetworkError } from '../common';
+
+export const mockStorage = localforage.createInstance({
+ name: 'mock-backend',
+ description: 'Database for mock back end.',
+ driver: localforage.INDEXEDDB,
+});
+
+export async function sha1(data: Blob): Promise<string> {
+ const s = await new Promise<string>((resolve) => {
+ const fileReader = new FileReader();
+ fileReader.readAsBinaryString(data);
+ fileReader.onload = () => {
+ resolve(fileReader.result as string);
+ };
+ });
+
+ return SHA1(s).toString();
+}
+
+const disableNetworkKey = 'mockServer.disableNetwork';
+
+let disableNetwork: boolean =
+ localStorage.getItem(disableNetworkKey) === 'true' ? true : false;
+
+Object.defineProperty(window, 'disableNetwork', {
+ get: () => disableNetwork,
+ set: (value) => {
+ if (value) {
+ disableNetwork = true;
+ localStorage.setItem(disableNetworkKey, 'true');
+ } else {
+ disableNetwork = false;
+ localStorage.setItem(disableNetworkKey, 'false');
+ }
+ },
+});
+
+export async function mockPrepare(): Promise<void> {
+ if (disableNetwork) {
+ console.warn('Network is disabled for mock server.');
+ throw new HttpNetworkError();
+ }
+ await Promise.resolve();
+}
diff --git a/Timeline/ClientApp/src/app/http/mock/default-avatar.png b/Timeline/ClientApp/src/app/http/mock/default-avatar.png
new file mode 100644
index 00000000..4086e1d2
--- /dev/null
+++ b/Timeline/ClientApp/src/app/http/mock/default-avatar.png
Binary files differ
diff --git a/Timeline/ClientApp/src/app/http/mock/install.ts b/Timeline/ClientApp/src/app/http/mock/install.ts
new file mode 100644
index 00000000..66174d41
--- /dev/null
+++ b/Timeline/ClientApp/src/app/http/mock/install.ts
@@ -0,0 +1,11 @@
+import { setHttpTokenClient } from '../token';
+import { setHttpUserClient } from '../user';
+import { setHttpTimelineClient } from '../timeline';
+
+import { MockHttpTokenClient } from './token';
+import { MockHttpUserClient } from './user';
+import { MockHttpTimelineClient } from './timeline';
+
+setHttpTokenClient(new MockHttpTokenClient());
+setHttpUserClient(new MockHttpUserClient());
+setHttpTimelineClient(new MockHttpTimelineClient());
diff --git a/Timeline/ClientApp/src/app/http/mock/timeline.ts b/Timeline/ClientApp/src/app/http/mock/timeline.ts
new file mode 100644
index 00000000..2a34ef10
--- /dev/null
+++ b/Timeline/ClientApp/src/app/http/mock/timeline.ts
@@ -0,0 +1,600 @@
+import { random, without, range } from 'lodash';
+
+import { BlobWithEtag, NotModified } from '../common';
+import {
+ IHttpTimelineClient,
+ HttpTimelineInfo,
+ TimelineVisibility,
+ HttpTimelineListQuery,
+ HttpTimelineNotExistError,
+ HttpTimelinePostRequest,
+ HttpTimelineNameConflictError,
+ HttpTimelinePatchRequest,
+ HttpTimelinePostInfo,
+ HttpTimelinePostContent,
+ HttpTimelinePostPostRequest,
+ HttpTimelinePostNotExistError,
+ HttpTimelineGenericPostInfo,
+} from '../timeline';
+import { HttpUser } from '../user';
+
+import { mockStorage, sha1, mockPrepare } from './common';
+import { getUser, MockUserNotExistError, checkToken } from './user';
+
+async function getTimelineNameList(): Promise<string[]> {
+ return (await mockStorage.getItem<string[]>('timelines')) ?? [];
+}
+
+async function setTimelineNameList(newOne: string[]): Promise<void> {
+ await mockStorage.setItem<string[]>('timelines', newOne);
+}
+
+type TimelinePropertyKey =
+ | 'uniqueId'
+ | 'owner'
+ | 'description'
+ | 'visibility'
+ | 'members'
+ | 'currentPostId';
+
+function getTimelinePropertyKey(
+ name: string,
+ property: TimelinePropertyKey
+): string {
+ return `timeline.${name}.${property}`;
+}
+
+function getTimelinePropertyValue<T>(
+ name: string,
+ property: TimelinePropertyKey
+): Promise<T> {
+ return mockStorage.getItem<T>(getTimelinePropertyKey(name, property));
+}
+
+function setTimelinePropertyValue<T>(
+ name: string,
+ property: TimelinePropertyKey,
+ value: T
+): Promise<void> {
+ return mockStorage
+ .setItem<T>(getTimelinePropertyKey(name, property), value)
+ .then();
+}
+
+interface HttpTimelineInfoEx extends HttpTimelineInfo {
+ memberUsernames: string[];
+}
+
+function createUniqueId(): string {
+ const s = 'abcdefghijklmnopqrstuvwxz0123456789';
+ let result = '';
+ for (let i = 0; i < 16; i++) {
+ result += s[random(0, s.length - 1)];
+ }
+ return result;
+}
+
+class MockTimelineNotExistError extends Error {
+ constructor() {
+ super('Timeline not exist.');
+ }
+}
+
+class MockTimelineAlreadyExistError extends Error {
+ constructor() {
+ super('Timeline already exist.');
+ }
+}
+
+async function getTimelineInfo(name: string): Promise<HttpTimelineInfoEx> {
+ let owner: HttpUser;
+ if (name.startsWith('@')) {
+ const ownerUsername = name.substr(1);
+ owner = await getUser(ownerUsername);
+ const optionalUniqueId = await getTimelinePropertyValue<string | null>(
+ name,
+ 'uniqueId'
+ );
+ if (optionalUniqueId == null) {
+ await setTimelineNameList([...(await getTimelineNameList()), name]);
+ await setTimelinePropertyValue(name, 'uniqueId', createUniqueId());
+ }
+ } else {
+ const optionalOwnerUsername = await getTimelinePropertyValue<string | null>(
+ name,
+ 'owner'
+ );
+ if (optionalOwnerUsername == null) {
+ throw new MockTimelineNotExistError();
+ } else {
+ owner = await getUser(optionalOwnerUsername);
+ }
+ }
+
+ const memberUsernames =
+ (await getTimelinePropertyValue<string[] | null>(name, 'members')) ?? [];
+ const members = await Promise.all(
+ memberUsernames.map(async (username) => {
+ return await getUser(username);
+ })
+ );
+
+ return {
+ name,
+ uniqueId: await getTimelinePropertyValue<string>(name, 'uniqueId'),
+ owner,
+ description:
+ (await getTimelinePropertyValue<string | null>(name, 'description')) ??
+ '',
+ visibility:
+ (await getTimelinePropertyValue<TimelineVisibility | null>(
+ name,
+ 'visibility'
+ )) ?? 'Register',
+ members,
+ memberUsernames,
+ };
+}
+
+async function createTimeline(name: string, owner: string): Promise<void> {
+ const optionalOwnerUsername = await getTimelinePropertyValue<string | null>(
+ name,
+ 'owner'
+ );
+ if (optionalOwnerUsername != null) {
+ throw new MockTimelineAlreadyExistError();
+ }
+
+ await setTimelineNameList([...(await getTimelineNameList()), name]);
+ await setTimelinePropertyValue(name, 'uniqueId', createUniqueId());
+ await setTimelinePropertyValue(name, 'owner', owner);
+}
+
+type TimelinePostPropertyKey =
+ | 'type'
+ | 'data'
+ | 'etag'
+ | 'author'
+ | 'time'
+ | 'lastUpdated';
+
+function getTimelinePostPropertyKey(
+ timelineName: string,
+ id: number,
+ propertyKey: TimelinePostPropertyKey
+): string {
+ return `timeline.${timelineName}.posts.${id}.${propertyKey}`;
+}
+
+function getTimelinePostPropertyValue<T>(
+ timelineName: string,
+ id: number,
+ propertyKey: TimelinePostPropertyKey
+): Promise<T> {
+ return mockStorage.getItem<T>(
+ getTimelinePostPropertyKey(timelineName, id, propertyKey)
+ );
+}
+
+function setTimelinePostPropertyValue<T>(
+ timelineName: string,
+ id: number,
+ propertyKey: TimelinePostPropertyKey,
+ value: T
+): Promise<T> {
+ return mockStorage.setItem(
+ getTimelinePostPropertyKey(timelineName, id, propertyKey),
+ value
+ );
+}
+
+function removeTimelinePostProperty(
+ timelineName: string,
+ id: number,
+ propertyKey: TimelinePostPropertyKey
+): Promise<void> {
+ return mockStorage.removeItem(
+ getTimelinePostPropertyKey(timelineName, id, propertyKey)
+ );
+}
+
+async function getTimelinePostInfo(
+ timelineName: string,
+ id: number
+): Promise<HttpTimelineGenericPostInfo> {
+ const currentPostId = await getTimelinePropertyValue<number | null>(
+ timelineName,
+ 'currentPostId'
+ );
+ if (currentPostId == null || id > currentPostId) {
+ throw new HttpTimelinePostNotExistError();
+ }
+
+ const type = await getTimelinePostPropertyValue<string | null>(
+ timelineName,
+ id,
+ 'type'
+ );
+
+ if (type == null) {
+ return {
+ id,
+ author: await getUser(
+ await getTimelinePostPropertyValue<string>(timelineName, id, 'author')
+ ),
+ time: new Date(
+ await getTimelinePostPropertyValue<string>(timelineName, id, 'time')
+ ),
+ lastUpdated: new Date(
+ await getTimelinePostPropertyValue<string>(
+ timelineName,
+ id,
+ 'lastUpdated'
+ )
+ ),
+ deleted: true,
+ };
+ } else {
+ let content: HttpTimelinePostContent;
+ if (type === 'text') {
+ content = {
+ type: 'text',
+ text: await getTimelinePostPropertyValue(timelineName, id, 'data'),
+ };
+ } else {
+ content = {
+ type: 'image',
+ };
+ }
+
+ return {
+ id,
+ author: await getUser(
+ await getTimelinePostPropertyValue<string>(timelineName, id, 'author')
+ ),
+ time: new Date(
+ await getTimelinePostPropertyValue<string>(timelineName, id, 'time')
+ ),
+ lastUpdated: new Date(
+ await getTimelinePostPropertyValue<string>(
+ timelineName,
+ id,
+ 'lastUpdated'
+ )
+ ),
+ content,
+ deleted: false,
+ };
+ }
+}
+
+export class MockHttpTimelineClient implements IHttpTimelineClient {
+ async listTimeline(
+ query: HttpTimelineListQuery
+ ): Promise<HttpTimelineInfo[]> {
+ await mockPrepare();
+ return (
+ await Promise.all(
+ (await getTimelineNameList()).map((name) => getTimelineInfo(name))
+ )
+ ).filter((timeline) => {
+ if (
+ query.visibility != null &&
+ query.visibility !== timeline.visibility
+ ) {
+ return false;
+ }
+ if (query.relate != null) {
+ if (query.relateType === 'own') {
+ if (timeline.owner.username !== query.relate) {
+ return false;
+ }
+ } else if (query.relateType === 'join') {
+ if (!timeline.memberUsernames.includes(query.relate)) {
+ return false;
+ }
+ } else if (
+ timeline.owner.username !== query.relate &&
+ !timeline.memberUsernames.includes(query.relate)
+ ) {
+ return false;
+ }
+ }
+ return true;
+ });
+ }
+
+ async getTimeline(timelineName: string): Promise<HttpTimelineInfo> {
+ await mockPrepare();
+ try {
+ return await getTimelineInfo(timelineName);
+ } catch (e) {
+ if (
+ e instanceof MockTimelineNotExistError ||
+ e instanceof MockUserNotExistError
+ ) {
+ throw new HttpTimelineNotExistError();
+ }
+ throw e;
+ }
+ }
+
+ async postTimeline(
+ req: HttpTimelinePostRequest,
+ token: string
+ ): Promise<HttpTimelineInfo> {
+ await mockPrepare();
+ const user = checkToken(token);
+ try {
+ await createTimeline(req.name, user);
+ } catch (e) {
+ if (e instanceof MockTimelineAlreadyExistError) {
+ throw new HttpTimelineNameConflictError();
+ }
+ throw e;
+ }
+ return await getTimelineInfo(req.name);
+ }
+
+ async patchTimeline(
+ timelineName: string,
+ req: HttpTimelinePatchRequest,
+ _token: string
+ ): Promise<HttpTimelineInfo> {
+ await mockPrepare();
+ if (req.description != null) {
+ await setTimelinePropertyValue(
+ timelineName,
+ 'description',
+ req.description
+ );
+ }
+ if (req.visibility != null) {
+ await setTimelinePropertyValue(
+ timelineName,
+ 'visibility',
+ req.visibility
+ );
+ }
+ return await getTimelineInfo(timelineName);
+ }
+
+ async deleteTimeline(timelineName: string, _token: string): Promise<void> {
+ await mockPrepare();
+ await setTimelineNameList(
+ without(await getTimelineNameList(), timelineName)
+ );
+ await mockStorage.removeItem(
+ getTimelinePropertyKey(timelineName, 'uniqueId')
+ );
+
+ // TODO: remove other things
+ }
+
+ async memberPut(
+ timelineName: string,
+ username: string,
+ _token: string
+ ): Promise<void> {
+ await mockPrepare();
+ const oldMembers =
+ (await getTimelinePropertyValue<string[] | null>(
+ timelineName,
+ 'members'
+ )) ?? [];
+ if (!oldMembers.includes(username)) {
+ await setTimelinePropertyValue(timelineName, 'members', [
+ ...oldMembers,
+ username,
+ ]);
+ }
+ }
+
+ async memberDelete(
+ timelineName: string,
+ username: string,
+ _token: string
+ ): Promise<void> {
+ await mockPrepare();
+ const oldMembers =
+ (await getTimelinePropertyValue<string[] | null>(
+ timelineName,
+ 'members'
+ )) ?? [];
+ if (oldMembers.includes(username)) {
+ await setTimelinePropertyValue(
+ timelineName,
+ 'members',
+ without(oldMembers, username)
+ );
+ }
+ }
+
+ listPost(
+ timelineName: string,
+ token?: string
+ ): Promise<HttpTimelinePostInfo[]>;
+ listPost(
+ timelineName: string,
+ token: string | undefined,
+ query: {
+ modifiedSince?: Date;
+ includeDeleted?: false;
+ }
+ ): Promise<HttpTimelinePostInfo[]>;
+ listPost(
+ timelineName: string,
+ token: string | undefined,
+ query: {
+ modifiedSince?: Date;
+ includeDeleted: true;
+ }
+ ): Promise<HttpTimelineGenericPostInfo[]>;
+ async listPost(
+ timelineName: string,
+ _token?: string,
+ query?: {
+ modifiedSince?: Date;
+ includeDeleted?: boolean;
+ }
+ ): Promise<HttpTimelineGenericPostInfo[]> {
+ await mockPrepare();
+ // TODO: Permission check.
+
+ const currentPostId = await getTimelinePropertyValue<number | null>(
+ timelineName,
+ 'currentPostId'
+ );
+
+ return (
+ await Promise.all(
+ range(1, currentPostId == null ? 1 : currentPostId + 1).map(
+ async (id) => {
+ return await getTimelinePostInfo(timelineName, id);
+ }
+ )
+ )
+ )
+ .filter((post) => {
+ if (query?.includeDeleted !== true && post.deleted) {
+ return false;
+ }
+ return true;
+ })
+ .filter((post) => {
+ if (query?.modifiedSince != null) {
+ return post.lastUpdated >= query.modifiedSince;
+ }
+ return true;
+ });
+ }
+
+ getPostData(
+ timelineName: string,
+ postId: number,
+ token: string
+ ): Promise<BlobWithEtag>;
+ async getPostData(
+ timelineName: string,
+ postId: number,
+ _token?: string,
+ etag?: string
+ ): Promise<BlobWithEtag | NotModified> {
+ await mockPrepare();
+ // TODO: Permission check.
+
+ const optionalSavedEtag = await getTimelinePostPropertyValue<string>(
+ timelineName,
+ postId,
+ 'etag'
+ );
+
+ if (optionalSavedEtag == null) {
+ const optionalType = await getTimelinePostPropertyValue<string>(
+ timelineName,
+ postId,
+ 'type'
+ );
+
+ if (optionalType != null) {
+ throw new Error('Post of this type has no data.');
+ } else {
+ throw new HttpTimelinePostNotExistError();
+ }
+ }
+
+ if (etag === optionalSavedEtag) {
+ return new NotModified();
+ }
+
+ return {
+ data: await getTimelinePostPropertyValue<Blob>(
+ timelineName,
+ postId,
+ 'data'
+ ),
+ etag: optionalSavedEtag,
+ };
+ }
+
+ async postPost(
+ timelineName: string,
+ req: HttpTimelinePostPostRequest,
+ token: string
+ ): Promise<HttpTimelinePostInfo> {
+ await mockPrepare();
+ const user = checkToken(token);
+
+ const savedId = await getTimelinePropertyValue<number | null>(
+ timelineName,
+ 'currentPostId'
+ );
+ const id = savedId ? savedId + 1 : 1;
+ await setTimelinePropertyValue(timelineName, 'currentPostId', id);
+
+ await setTimelinePostPropertyValue(timelineName, id, 'author', user);
+
+ const currentTimeString = new Date().toISOString();
+ await setTimelinePostPropertyValue(
+ timelineName,
+ id,
+ 'lastUpdated',
+ currentTimeString
+ );
+
+ await setTimelinePostPropertyValue(
+ timelineName,
+ id,
+ 'time',
+ req.time != null ? req.time.toISOString() : currentTimeString
+ );
+
+ const { content } = req;
+ if (content.type === 'text') {
+ await setTimelinePostPropertyValue(timelineName, id, 'type', 'text');
+ await setTimelinePostPropertyValue(
+ timelineName,
+ id,
+ 'data',
+ content.text
+ );
+ } else {
+ await setTimelinePostPropertyValue(timelineName, id, 'type', 'image');
+ await setTimelinePostPropertyValue(
+ timelineName,
+ id,
+ 'data',
+ content.data
+ );
+ await setTimelinePostPropertyValue(
+ timelineName,
+ id,
+ 'etag',
+ await sha1(content.data)
+ );
+ }
+
+ return (await getTimelinePostInfo(
+ timelineName,
+ id
+ )) as HttpTimelinePostInfo;
+ }
+
+ async deletePost(
+ timelineName: string,
+ postId: number,
+ _token: string
+ ): Promise<void> {
+ await mockPrepare();
+ // TODO: permission check
+ await removeTimelinePostProperty(timelineName, postId, 'type');
+ await removeTimelinePostProperty(timelineName, postId, 'data');
+ await removeTimelinePostProperty(timelineName, postId, 'etag');
+ await setTimelinePostPropertyValue(
+ timelineName,
+ postId,
+ 'lastUpdated',
+ new Date().toISOString()
+ );
+ }
+}
diff --git a/Timeline/ClientApp/src/app/http/mock/token.ts b/Timeline/ClientApp/src/app/http/mock/token.ts
new file mode 100644
index 00000000..6924e7d7
--- /dev/null
+++ b/Timeline/ClientApp/src/app/http/mock/token.ts
@@ -0,0 +1,53 @@
+import { AxiosError } from 'axios';
+
+import {
+ IHttpTokenClient,
+ HttpCreateTokenRequest,
+ HttpCreateTokenResponse,
+ HttpVerifyTokenRequest,
+ HttpVerifyTokenResponse,
+} from '../token';
+
+import { mockPrepare } from './common';
+import { getUser, MockUserNotExistError, checkToken } from './user';
+
+export class MockHttpTokenClient implements IHttpTokenClient {
+ // TODO: Mock bad credentials error.
+ async create(req: HttpCreateTokenRequest): Promise<HttpCreateTokenResponse> {
+ await mockPrepare();
+ try {
+ const user = await getUser(req.username);
+ return {
+ user,
+ token: `token-${req.username}`,
+ };
+ } catch (e) {
+ if (e instanceof MockUserNotExistError) {
+ throw {
+ isAxiosError: true,
+ response: {
+ status: 400,
+ },
+ } as Partial<AxiosError>;
+ }
+ throw e;
+ }
+ }
+
+ async verify(req: HttpVerifyTokenRequest): Promise<HttpVerifyTokenResponse> {
+ await mockPrepare();
+ try {
+ const user = await getUser(checkToken(req.token));
+ return {
+ user,
+ };
+ } catch (e) {
+ throw {
+ isAxiosError: true,
+ response: {
+ status: 400,
+ },
+ } as Partial<AxiosError>;
+ }
+ }
+}
diff --git a/Timeline/ClientApp/src/app/http/mock/user.ts b/Timeline/ClientApp/src/app/http/mock/user.ts
new file mode 100644
index 00000000..d16302d4
--- /dev/null
+++ b/Timeline/ClientApp/src/app/http/mock/user.ts
@@ -0,0 +1,132 @@
+import axios from 'axios';
+
+import { BlobWithEtag, NotModified } from '../common';
+import {
+ IHttpUserClient,
+ HttpUser,
+ HttpUserNotExistError,
+ HttpUserPatchRequest,
+ HttpChangePasswordRequest,
+} from '../user';
+
+import { mockStorage, sha1, mockPrepare } from './common';
+
+import defaultAvatarUrl from './default-avatar.png';
+
+let _defaultAvatar: BlobWithEtag | undefined = undefined;
+
+async function getDefaultAvatar(): Promise<BlobWithEtag> {
+ if (_defaultAvatar == null) {
+ const blob = (
+ await axios.get<Blob>(defaultAvatarUrl, {
+ responseType: 'blob',
+ })
+ ).data;
+ const etag = await sha1(blob);
+ _defaultAvatar = {
+ data: blob,
+ etag,
+ };
+ }
+ return _defaultAvatar;
+}
+
+export class MockTokenError extends Error {
+ constructor() {
+ super('Token bad format.');
+ }
+}
+
+export class MockUserNotExistError extends Error {
+ constructor() {
+ super('Only two user "user" and "admin".');
+ }
+}
+
+export function checkUsername(username: string): void {
+ if (!['user', 'admin'].includes(username)) throw new MockUserNotExistError();
+}
+
+export function checkToken(token: string): string {
+ if (!token.startsWith('token-')) {
+ throw new MockTokenError();
+ }
+ return token.substr(6);
+}
+
+export async function getUser(
+ username: 'user' | 'admin' | string
+): Promise<HttpUser> {
+ checkUsername(username);
+ const savedNickname = await mockStorage.getItem<string>(
+ `user.${username}.nickname`
+ );
+ return {
+ username: username,
+ nickname:
+ savedNickname == null || savedNickname === '' ? username : savedNickname,
+ administrator: username === 'admin',
+ };
+}
+
+export class MockHttpUserClient implements IHttpUserClient {
+ async get(username: string): Promise<HttpUser> {
+ await mockPrepare();
+ return await getUser(username).catch((e) => {
+ if (e instanceof MockUserNotExistError) {
+ throw new HttpUserNotExistError();
+ } else {
+ throw e;
+ }
+ });
+ }
+
+ async patch(
+ username: string,
+ req: HttpUserPatchRequest,
+ _token: string
+ ): Promise<HttpUser> {
+ await mockPrepare();
+ if (req.nickname != null) {
+ await mockStorage.setItem(`user.${username}.nickname`, req.nickname);
+ }
+ return await getUser(username);
+ }
+
+ getAvatar(username: string): Promise<BlobWithEtag>;
+ async getAvatar(
+ username: string,
+ etag?: string
+ ): Promise<BlobWithEtag | NotModified> {
+ await mockPrepare();
+
+ const savedEtag = await mockStorage.getItem(`user.${username}.avatar.etag`);
+ if (savedEtag == null) {
+ return await getDefaultAvatar();
+ }
+
+ if (savedEtag === etag) {
+ return new NotModified();
+ }
+
+ return {
+ data: await mockStorage.getItem<Blob>(`user.${username}.avatar.data`),
+ etag: await mockStorage.getItem<string>(`user.${username}.avatar.etag`),
+ };
+ }
+
+ async putAvatar(username: string, data: Blob, _token: string): Promise<void> {
+ await mockPrepare();
+ const etag = await sha1(data);
+ await mockStorage.setItem<Blob>(`user.${username}.avatar.data`, data);
+ await mockStorage.setItem<string>(`user.${username}.avatar.etag`, etag);
+ }
+
+ async changePassword(
+ _req: HttpChangePasswordRequest,
+ _token: string
+ ): Promise<void> {
+ await mockPrepare();
+ throw new Error('Not Implemented.');
+ }
+}
diff --git a/Timeline/ClientApp/src/app/http/timeline.ts b/Timeline/ClientApp/src/app/http/timeline.ts
new file mode 100644
index 00000000..458ea6e6
--- /dev/null
+++ b/Timeline/ClientApp/src/app/http/timeline.ts
@@ -0,0 +1,476 @@
+import axios, { AxiosError } from 'axios';
+
+import { updateQueryString, applyQueryParameters } from '../utilities/url';
+import {
+ apiBaseUrl,
+ extractResponseData,
+ convertToNetworkError,
+ base64,
+ convertToIfStatusCodeIs,
+ convertToIfErrorCodeIs,
+ BlobWithEtag,
+ NotModified,
+ convertToBlobWithEtagOrNotModified,
+} from './common';
+import { HttpUser } from './user';
+
+export const kTimelineVisibilities = ['Public', 'Register', 'Private'] as const;
+
+export type TimelineVisibility = typeof kTimelineVisibilities[number];
+
+export interface HttpTimelineInfo {
+ uniqueId: string;
+ name: string;
+ description: string;
+ owner: HttpUser;
+ visibility: TimelineVisibility;
+ members: HttpUser[];
+}
+
+export interface HttpTimelineListQuery {
+ visibility?: TimelineVisibility;
+ relate?: string;
+ relateType?: 'own' | 'join';
+}
+
+export interface HttpTimelinePostRequest {
+ name: string;
+}
+
+export interface HttpTimelinePostTextContent {
+ type: 'text';
+ text: string;
+}
+
+export interface HttpTimelinePostImageContent {
+ type: 'image';
+}
+
+export type HttpTimelinePostContent =
+ | HttpTimelinePostTextContent
+ | HttpTimelinePostImageContent;
+
+export interface HttpTimelinePostInfo {
+ id: number;
+ content: HttpTimelinePostContent;
+ time: Date;
+ lastUpdated: Date;
+ author: HttpUser;
+ deleted: false;
+}
+
+export interface HttpTimelineDeletedPostInfo {
+ id: number;
+ time: Date;
+ lastUpdated: Date;
+ author: HttpUser;
+ deleted: true;
+}
+
+export type HttpTimelineGenericPostInfo =
+ | HttpTimelinePostInfo
+ | HttpTimelineDeletedPostInfo;
+
+export interface HttpTimelinePostPostRequestTextContent {
+ type: 'text';
+ text: string;
+}
+
+export interface HttpTimelinePostPostRequestImageContent {
+ type: 'image';
+ data: Blob;
+}
+
+export type HttpTimelinePostPostRequestContent =
+ | HttpTimelinePostPostRequestTextContent
+ | HttpTimelinePostPostRequestImageContent;
+
+export interface HttpTimelinePostPostRequest {
+ content: HttpTimelinePostPostRequestContent;
+ time?: Date;
+}
+
+export interface HttpTimelinePatchRequest {
+ visibility?: TimelineVisibility;
+ description?: string;
+}
+
+export class HttpTimelineNotExistError extends Error {
+ constructor(public innerError?: AxiosError) {
+ super();
+ }
+}
+
+export class HttpTimelinePostNotExistError extends Error {
+ constructor(public innerError?: AxiosError) {
+ super();
+ }
+}
+
+export class HttpTimelineNameConflictError extends Error {
+ constructor(public innerError?: AxiosError) {
+ super();
+ }
+}
+
+//-------------------- begin: internal model --------------------
+
+interface RawTimelinePostTextContent {
+ type: 'text';
+ text: string;
+}
+
+interface RawTimelinePostImageContent {
+ type: 'image';
+ url: string;
+}
+
+type RawTimelinePostContent =
+ | RawTimelinePostTextContent
+ | RawTimelinePostImageContent;
+
+interface RawTimelinePostInfo {
+ id: number;
+ content: HttpTimelinePostContent;
+ time: string;
+ lastUpdated: string;
+ author: HttpUser;
+ deleted: false;
+}
+
+interface RawTimelineDeletedPostInfo {
+ id: number;
+ time: string;
+ lastUpdated: string;
+ author: HttpUser;
+ deleted: true;
+}
+
+type RawTimelineGenericPostInfo =
+ | RawTimelinePostInfo
+ | RawTimelineDeletedPostInfo;
+
+interface RawTimelinePostPostRequestTextContent {
+ type: 'text';
+ text: string;
+}
+
+interface RawTimelinePostPostRequestImageContent {
+ type: 'image';
+ data: string;
+}
+
+type RawTimelinePostPostRequestContent =
+ | RawTimelinePostPostRequestTextContent
+ | RawTimelinePostPostRequestImageContent;
+
+interface RawTimelinePostPostRequest {
+ content: RawTimelinePostPostRequestContent;
+ time?: string;
+}
+
+//-------------------- end: internal model --------------------
+
+function processRawTimelinePostInfo(
+ raw: RawTimelinePostInfo
+): HttpTimelinePostInfo;
+function processRawTimelinePostInfo(
+ raw: RawTimelineGenericPostInfo
+): HttpTimelineGenericPostInfo;
+function processRawTimelinePostInfo(
+ raw: RawTimelineGenericPostInfo
+): HttpTimelineGenericPostInfo {
+ return {
+ ...raw,
+ time: new Date(raw.time),
+ lastUpdated: new Date(raw.lastUpdated),
+ };
+}
+
+export interface IHttpTimelineClient {
+ listTimeline(query: HttpTimelineListQuery): Promise<HttpTimelineInfo[]>;
+ getTimeline(timelineName: string): Promise<HttpTimelineInfo>;
+ postTimeline(
+ req: HttpTimelinePostRequest,
+ token: string
+ ): Promise<HttpTimelineInfo>;
+ patchTimeline(
+ timelineName: string,
+ req: HttpTimelinePatchRequest,
+ token: string
+ ): Promise<HttpTimelineInfo>;
+ deleteTimeline(timelineName: string, token: string): Promise<void>;
+ memberPut(
+ timelineName: string,
+ username: string,
+ token: string
+ ): Promise<void>;
+ memberDelete(
+ timelineName: string,
+ username: string,
+ token: string
+ ): Promise<void>;
+ listPost(
+ timelineName: string,
+ token?: string
+ ): Promise<HttpTimelinePostInfo[]>;
+ listPost(
+ timelineName: string,
+ token: string | undefined,
+ query: {
+ modifiedSince?: Date;
+ includeDeleted?: false;
+ }
+ ): Promise<HttpTimelinePostInfo[]>;
+ listPost(
+ timelineName: string,
+ token: string | undefined,
+ query: {
+ modifiedSince?: Date;
+ includeDeleted: true;
+ }
+ ): Promise<HttpTimelineGenericPostInfo[]>;
+ getPostData(
+ timelineName: string,
+ postId: number,
+ token?: string
+ ): Promise<BlobWithEtag>;
+ getPostData(
+ timelineName: string,
+ postId: number,
+ token: string | undefined,
+ etag: string
+ ): Promise<BlobWithEtag | NotModified>;
+ postPost(
+ timelineName: string,
+ req: HttpTimelinePostPostRequest,
+ token: string
+ ): Promise<HttpTimelinePostInfo>;
+ deletePost(
+ timelineName: string,
+ postId: number,
+ token: string
+ ): Promise<void>;
+}
+
+export class HttpTimelineClient implements IHttpTimelineClient {
+ listTimeline(query: HttpTimelineListQuery): Promise<HttpTimelineInfo[]> {
+ return axios
+ .get<HttpTimelineInfo[]>(
+ applyQueryParameters(`${apiBaseUrl}/timelines`, query)
+ )
+ .then(extractResponseData)
+ .catch(convertToNetworkError);
+ }
+
+ getTimeline(timelineName: string): Promise<HttpTimelineInfo> {
+ return axios
+ .get<HttpTimelineInfo>(`${apiBaseUrl}/timelines/${timelineName}`)
+ .then(extractResponseData)
+ .catch(convertToIfStatusCodeIs(404, HttpTimelineNotExistError))
+ .catch(convertToNetworkError);
+ }
+
+ postTimeline(
+ req: HttpTimelinePostRequest,
+ token: string
+ ): Promise<HttpTimelineInfo> {
+ return axios
+ .post<HttpTimelineInfo>(`${apiBaseUrl}/timelines?token=${token}`, req)
+ .then(extractResponseData)
+ .catch(convertToIfErrorCodeIs(11040101, HttpTimelineNameConflictError))
+ .catch(convertToNetworkError);
+ }
+
+ patchTimeline(
+ timelineName: string,
+ req: HttpTimelinePatchRequest,
+ token: string
+ ): Promise<HttpTimelineInfo> {
+ return axios
+ .patch<HttpTimelineInfo>(
+ `${apiBaseUrl}/timelines/${timelineName}?token=${token}`,
+ req
+ )
+ .then(extractResponseData)
+ .catch(convertToNetworkError);
+ }
+
+ deleteTimeline(timelineName: string, token: string): Promise<void> {
+ return axios
+ .delete(`${apiBaseUrl}/timelines/${timelineName}?token=${token}`)
+ .catch(convertToNetworkError)
+ .then();
+ }
+
+ memberPut(
+ timelineName: string,
+ username: string,
+ token: string
+ ): Promise<void> {
+ return axios
+ .put(
+ `${apiBaseUrl}/timelines/${timelineName}/members/${username}?token=${token}`
+ )
+ .catch(convertToNetworkError)
+ .then();
+ }
+
+ memberDelete(
+ timelineName: string,
+ username: string,
+ token: string
+ ): Promise<void> {
+ return axios
+ .delete(
+ `${apiBaseUrl}/timelines/${timelineName}/members/${username}?token=${token}`
+ )
+ .catch(convertToNetworkError)
+ .then();
+ }
+
+ listPost(
+ timelineName: string,
+ token?: string
+ ): Promise<HttpTimelinePostInfo[]>;
+ listPost(
+ timelineName: string,
+ token: string | undefined,
+ query: {
+ modifiedSince?: Date;
+ includeDeleted?: false;
+ }
+ ): Promise<HttpTimelinePostInfo[]>;
+ listPost(
+ timelineName: string,
+ token: string | undefined,
+ query: {
+ modifiedSince?: Date;
+ includeDeleted: true;
+ }
+ ): Promise<HttpTimelineGenericPostInfo[]>;
+ listPost(
+ timelineName: string,
+ token?: string,
+ query?: {
+ modifiedSince?: Date;
+ includeDeleted?: boolean;
+ }
+ ): Promise<HttpTimelineGenericPostInfo[]> {
+ let url = `${apiBaseUrl}/timelines/${timelineName}/posts`;
+ url = updateQueryString('token', token, url);
+ if (query != null) {
+ if (query.modifiedSince != null) {
+ url = updateQueryString(
+ 'modifiedSince',
+ query.modifiedSince.toISOString(),
+ url
+ );
+ }
+ if (query.includeDeleted != null) {
+ url = updateQueryString(
+ 'includeDeleted',
+ query.includeDeleted ? 'true' : 'false',
+ url
+ );
+ }
+ }
+
+ return axios
+ .get<RawTimelineGenericPostInfo[]>(url)
+ .then(extractResponseData)
+ .catch(convertToNetworkError)
+ .then((rawPosts) =>
+ rawPosts.map((raw) => processRawTimelinePostInfo(raw))
+ );
+ }
+
+ getPostData(
+ timelineName: string,
+ postId: number,
+ token: string
+ ): Promise<BlobWithEtag>;
+ getPostData(
+ timelineName: string,
+ postId: number,
+ token?: string,
+ etag?: string
+ ): Promise<BlobWithEtag | NotModified> {
+ const headers =
+ etag != null
+ ? {
+ 'If-None-Match': etag,
+ }
+ : undefined;
+
+ let url = `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}/data`;
+ url = updateQueryString('token', token, url);
+
+ return axios
+ .get(url, {
+ responseType: 'blob',
+ headers,
+ })
+ .then(convertToBlobWithEtagOrNotModified)
+ .catch(convertToIfStatusCodeIs(404, HttpTimelinePostNotExistError))
+ .catch(convertToNetworkError);
+ }
+
+ async postPost(
+ timelineName: string,
+ req: HttpTimelinePostPostRequest,
+ token: string
+ ): Promise<HttpTimelinePostInfo> {
+ let content: RawTimelinePostPostRequestContent;
+ if (req.content.type === 'image') {
+ const base64Data = await base64(req.content.data);
+ content = {
+ ...req.content,
+ data: base64Data,
+ } as RawTimelinePostPostRequestImageContent;
+ } else {
+ content = req.content;
+ }
+ const rawReq: RawTimelinePostPostRequest = {
+ content,
+ };
+ if (req.time != null) {
+ rawReq.time = req.time.toISOString();
+ }
+ return await axios
+ .post<RawTimelinePostInfo>(
+ `${apiBaseUrl}/timelines/${timelineName}/posts?token=${token}`,
+ rawReq
+ )
+ .then(extractResponseData)
+ .catch(convertToNetworkError)
+ .then((rawPost) => processRawTimelinePostInfo(rawPost));
+ }
+
+ deletePost(
+ timelineName: string,
+ postId: number,
+ token: string
+ ): Promise<void> {
+ return axios
+ .delete(
+ `${apiBaseUrl}/timelines/${timelineName}/posts/${postId}?token=${token}`
+ )
+ .catch(convertToNetworkError)
+ .then();
+ }
+}
+
+let client: IHttpTimelineClient = new HttpTimelineClient();
+
+export function getHttpTimelineClient(): IHttpTimelineClient {
+ return client;
+}
+
+export function setHttpTimelineClient(
+ newClient: IHttpTimelineClient
+): IHttpTimelineClient {
+ const old = client;
+ client = newClient;
+ return old;
+}
diff --git a/Timeline/ClientApp/src/app/http/token.ts b/Timeline/ClientApp/src/app/http/token.ts
new file mode 100644
index 00000000..26f7891f
--- /dev/null
+++ b/Timeline/ClientApp/src/app/http/token.ts
@@ -0,0 +1,72 @@
+import axios, { AxiosError } from 'axios';
+
+import {
+ apiBaseUrl,
+ convertToNetworkError,
+ convertToIfErrorCodeIs,
+ extractResponseData,
+} from './common';
+import { HttpUser } from './user';
+
+export interface HttpCreateTokenRequest {
+ username: string;
+ password: string;
+ expire: number;
+}
+
+export interface HttpCreateTokenResponse {
+ token: string;
+ user: HttpUser;
+}
+
+export interface HttpVerifyTokenRequest {
+ token: string;
+}
+
+export interface HttpVerifyTokenResponse {
+ user: HttpUser;
+}
+
+export class HttpCreateTokenBadCredentialError extends Error {
+ constructor(public innerError?: AxiosError) {
+ super();
+ }
+}
+
+export interface IHttpTokenClient {
+ create(req: HttpCreateTokenRequest): Promise<HttpCreateTokenResponse>;
+ verify(req: HttpVerifyTokenRequest): Promise<HttpVerifyTokenResponse>;
+}
+
+export class HttpTokenClient implements IHttpTokenClient {
+ create(req: HttpCreateTokenRequest): Promise<HttpCreateTokenResponse> {
+ return axios
+ .post<HttpCreateTokenResponse>(`${apiBaseUrl}/token/create`, req)
+ .then(extractResponseData)
+ .catch(
+ convertToIfErrorCodeIs(11010101, HttpCreateTokenBadCredentialError)
+ )
+ .catch(convertToNetworkError);
+ }
+
+ verify(req: HttpVerifyTokenRequest): Promise<HttpVerifyTokenResponse> {
+ return axios
+ .post<HttpVerifyTokenResponse>(`${apiBaseUrl}/token/verify`, req)
+ .then(extractResponseData)
+ .catch(convertToNetworkError);
+ }
+}
+
+let client: IHttpTokenClient = new HttpTokenClient();
+
+export function getHttpTokenClient(): IHttpTokenClient {
+ return client;
+}
+
+export function setHttpTokenClient(
+ newClient: IHttpTokenClient
+): IHttpTokenClient {
+ const old = client;
+ client = newClient;
+ return old;
+}
diff --git a/Timeline/ClientApp/src/app/http/user.ts b/Timeline/ClientApp/src/app/http/user.ts
new file mode 100644
index 00000000..345a5e8b
--- /dev/null
+++ b/Timeline/ClientApp/src/app/http/user.ts
@@ -0,0 +1,131 @@
+import axios, { AxiosError } from 'axios';
+
+import {
+ apiBaseUrl,
+ convertToNetworkError,
+ extractResponseData,
+ convertToIfStatusCodeIs,
+ convertToIfErrorCodeIs,
+ NotModified,
+ BlobWithEtag,
+ convertToBlobWithEtagOrNotModified,
+} from './common';
+
+export interface HttpUser {
+ username: string;
+ administrator: boolean;
+ nickname: string;
+}
+
+export interface HttpUserPatchRequest {
+ nickname?: string;
+}
+
+export interface HttpChangePasswordRequest {
+ oldPassword: string;
+ newPassword: string;
+}
+
+export class HttpUserNotExistError extends Error {
+ constructor(public innerError?: AxiosError) {
+ super();
+ }
+}
+
+export class HttpChangePasswordBadCredentialError extends Error {
+ constructor(public innerError?: AxiosError) {
+ super();
+ }
+}
+
+export interface IHttpUserClient {
+ get(username: string): Promise<HttpUser>;
+ patch(
+ username: string,
+ req: HttpUserPatchRequest,
+ token: string
+ ): Promise<HttpUser>;
+ getAvatar(username: string): Promise<BlobWithEtag>;
+ getAvatar(
+ username: string,
+ etag: string
+ ): Promise<BlobWithEtag | NotModified>;
+ putAvatar(username: string, data: Blob, token: string): Promise<void>;
+ changePassword(req: HttpChangePasswordRequest, token: string): Promise<void>;
+}
+
+export class HttpUserClient implements IHttpUserClient {
+ get(username: string): Promise<HttpUser> {
+ return axios
+ .get<HttpUser>(`${apiBaseUrl}/users/${username}`)
+ .then(extractResponseData)
+ .catch(convertToIfStatusCodeIs(404, HttpUserNotExistError))
+ .catch(convertToNetworkError);
+ }
+
+ patch(
+ username: string,
+ req: HttpUserPatchRequest,
+ token: string
+ ): Promise<HttpUser> {
+ return axios
+ .patch<HttpUser>(`${apiBaseUrl}/users/${username}?token=${token}`, req)
+ .then(extractResponseData)
+ .catch(convertToNetworkError);
+ }
+
+ getAvatar(username: string): Promise<BlobWithEtag>;
+ getAvatar(
+ username: string,
+ etag?: string
+ ): Promise<BlobWithEtag | NotModified> {
+ const headers =
+ etag != null
+ ? {
+ 'If-None-Match': etag,
+ }
+ : undefined;
+
+ return axios
+ .get(`${apiBaseUrl}/users/${username}/avatar`, {
+ responseType: 'blob',
+ headers,
+ })
+ .then(convertToBlobWithEtagOrNotModified)
+ .catch(convertToIfStatusCodeIs(404, HttpUserNotExistError))
+ .catch(convertToNetworkError);
+ }
+
+ putAvatar(username: string, data: Blob, token: string): Promise<void> {
+ return axios
+ .put(`${apiBaseUrl}/users/${username}/avatar?token=${token}`, data, {
+ headers: {
+ 'Content-Type': data.type,
+ },
+ })
+ .catch(convertToNetworkError)
+ .then();
+ }
+
+ changePassword(req: HttpChangePasswordRequest, token: string): Promise<void> {
+ return axios
+ .post(`${apiBaseUrl}/userop/changepassword?token=${token}`, req)
+ .catch(
+ convertToIfErrorCodeIs(11020201, HttpChangePasswordBadCredentialError)
+ )
+ .catch(convertToNetworkError)
+ .then();
+ }
+}
+
+let client: IHttpUserClient = new HttpUserClient();
+
+export function getHttpUserClient(): IHttpUserClient {
+ return client;
+}
+
+export function setHttpUserClient(newClient: IHttpUserClient): IHttpUserClient {
+ const old = client;
+ client = newClient;
+ return old;
+}