From b78d21a524f7a11ad29b4bd230f23825f80c3ed7 Mon Sep 17 00:00:00 2001 From: crupest Date: Sun, 26 Jul 2020 15:02:55 +0800 Subject: Merge front end repo --- Timeline/ClientApp/src/app/http/common.ts | 140 +++++ Timeline/ClientApp/src/app/http/mock/common.ts | 48 ++ .../ClientApp/src/app/http/mock/default-avatar.png | Bin 0 -> 26442 bytes Timeline/ClientApp/src/app/http/mock/install.ts | 11 + Timeline/ClientApp/src/app/http/mock/timeline.ts | 600 +++++++++++++++++++++ Timeline/ClientApp/src/app/http/mock/token.ts | 53 ++ Timeline/ClientApp/src/app/http/mock/user.ts | 132 +++++ Timeline/ClientApp/src/app/http/timeline.ts | 476 ++++++++++++++++ Timeline/ClientApp/src/app/http/token.ts | 72 +++ Timeline/ClientApp/src/app/http/user.ts | 131 +++++ 10 files changed, 1663 insertions(+) create mode 100644 Timeline/ClientApp/src/app/http/common.ts create mode 100644 Timeline/ClientApp/src/app/http/mock/common.ts create mode 100644 Timeline/ClientApp/src/app/http/mock/default-avatar.png create mode 100644 Timeline/ClientApp/src/app/http/mock/install.ts create mode 100644 Timeline/ClientApp/src/app/http/mock/timeline.ts create mode 100644 Timeline/ClientApp/src/app/http/mock/token.ts create mode 100644 Timeline/ClientApp/src/app/http/mock/user.ts create mode 100644 Timeline/ClientApp/src/app/http/timeline.ts create mode 100644 Timeline/ClientApp/src/app/http/token.ts create mode 100644 Timeline/ClientApp/src/app/http/user.ts (limited to 'Timeline/ClientApp/src/app/http') 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 { + return new Promise((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 +): 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(res: AxiosResponse): T { + return res.data; +} + +export function catchIfStatusCodeIs< + TResult, + TErrorHandlerResult extends TResult | PromiseLike | null | undefined +>( + statusCode: number, + errorHandler: (error: AxiosError) => TErrorHandlerResult +): (error: AxiosError) => TErrorHandlerResult { + return (error: AxiosError) => { + if (extractStatusCode(error) == statusCode) { + return errorHandler(error); + } else { + throw error; + } + }; +} + +export function convertToIfStatusCodeIs( + statusCode: number, + newErrorType: { + new (innerError: AxiosError): NewError; + } +): (error: AxiosError) => never { + return catchIfStatusCodeIs(statusCode, (error) => { + throw new newErrorType(error); + }); +} + +export function catchIfErrorCodeIs< + TResult, + TErrorHandlerResult extends TResult | PromiseLike | null | undefined +>( + errorCode: number, + errorHandler: (error: AxiosError) => TErrorHandlerResult +): (error: AxiosError) => TErrorHandlerResult { + return (error: AxiosError) => { + if (extractErrorCode(error) == errorCode) { + return errorHandler(error); + } else { + throw error; + } + }; +} +export function convertToIfErrorCodeIs( + errorCode: number, + newErrorType: { + new (innerError: AxiosError): NewError; + } +): (error: AxiosError) => never { + return catchIfErrorCodeIs(errorCode, (error) => { + throw new newErrorType(error); + }); +} + +export function convertToNetworkError( + error: AxiosError +): never { + if (error.isAxiosError && error.response == null) { + throw new HttpNetworkError(error); + } else { + throw error; + } +} + +export function convertToBlobWithEtag(res: AxiosResponse): BlobWithEtag { + return { + data: res.data, + etag: (res.headers as Record<'etag', string>)['etag'], + }; +} + +export function convertToBlobWithEtagOrNotModified( + res: AxiosResponse +): 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 { + const s = await new Promise((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 { + 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 Binary files /dev/null and b/Timeline/ClientApp/src/app/http/mock/default-avatar.png 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 { + return (await mockStorage.getItem('timelines')) ?? []; +} + +async function setTimelineNameList(newOne: string[]): Promise { + await mockStorage.setItem('timelines', newOne); +} + +type TimelinePropertyKey = + | 'uniqueId' + | 'owner' + | 'description' + | 'visibility' + | 'members' + | 'currentPostId'; + +function getTimelinePropertyKey( + name: string, + property: TimelinePropertyKey +): string { + return `timeline.${name}.${property}`; +} + +function getTimelinePropertyValue( + name: string, + property: TimelinePropertyKey +): Promise { + return mockStorage.getItem(getTimelinePropertyKey(name, property)); +} + +function setTimelinePropertyValue( + name: string, + property: TimelinePropertyKey, + value: T +): Promise { + return mockStorage + .setItem(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 { + let owner: HttpUser; + if (name.startsWith('@')) { + const ownerUsername = name.substr(1); + owner = await getUser(ownerUsername); + const optionalUniqueId = await getTimelinePropertyValue( + name, + 'uniqueId' + ); + if (optionalUniqueId == null) { + await setTimelineNameList([...(await getTimelineNameList()), name]); + await setTimelinePropertyValue(name, 'uniqueId', createUniqueId()); + } + } else { + const optionalOwnerUsername = await getTimelinePropertyValue( + name, + 'owner' + ); + if (optionalOwnerUsername == null) { + throw new MockTimelineNotExistError(); + } else { + owner = await getUser(optionalOwnerUsername); + } + } + + const memberUsernames = + (await getTimelinePropertyValue(name, 'members')) ?? []; + const members = await Promise.all( + memberUsernames.map(async (username) => { + return await getUser(username); + }) + ); + + return { + name, + uniqueId: await getTimelinePropertyValue(name, 'uniqueId'), + owner, + description: + (await getTimelinePropertyValue(name, 'description')) ?? + '', + visibility: + (await getTimelinePropertyValue( + name, + 'visibility' + )) ?? 'Register', + members, + memberUsernames, + }; +} + +async function createTimeline(name: string, owner: string): Promise { + const optionalOwnerUsername = await getTimelinePropertyValue( + 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( + timelineName: string, + id: number, + propertyKey: TimelinePostPropertyKey +): Promise { + return mockStorage.getItem( + getTimelinePostPropertyKey(timelineName, id, propertyKey) + ); +} + +function setTimelinePostPropertyValue( + timelineName: string, + id: number, + propertyKey: TimelinePostPropertyKey, + value: T +): Promise { + return mockStorage.setItem( + getTimelinePostPropertyKey(timelineName, id, propertyKey), + value + ); +} + +function removeTimelinePostProperty( + timelineName: string, + id: number, + propertyKey: TimelinePostPropertyKey +): Promise { + return mockStorage.removeItem( + getTimelinePostPropertyKey(timelineName, id, propertyKey) + ); +} + +async function getTimelinePostInfo( + timelineName: string, + id: number +): Promise { + const currentPostId = await getTimelinePropertyValue( + timelineName, + 'currentPostId' + ); + if (currentPostId == null || id > currentPostId) { + throw new HttpTimelinePostNotExistError(); + } + + const type = await getTimelinePostPropertyValue( + timelineName, + id, + 'type' + ); + + if (type == null) { + return { + id, + author: await getUser( + await getTimelinePostPropertyValue(timelineName, id, 'author') + ), + time: new Date( + await getTimelinePostPropertyValue(timelineName, id, 'time') + ), + lastUpdated: new Date( + await getTimelinePostPropertyValue( + 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(timelineName, id, 'author') + ), + time: new Date( + await getTimelinePostPropertyValue(timelineName, id, 'time') + ), + lastUpdated: new Date( + await getTimelinePostPropertyValue( + timelineName, + id, + 'lastUpdated' + ) + ), + content, + deleted: false, + }; + } +} + +export class MockHttpTimelineClient implements IHttpTimelineClient { + async listTimeline( + query: HttpTimelineListQuery + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + await mockPrepare(); + const oldMembers = + (await getTimelinePropertyValue( + timelineName, + 'members' + )) ?? []; + if (!oldMembers.includes(username)) { + await setTimelinePropertyValue(timelineName, 'members', [ + ...oldMembers, + username, + ]); + } + } + + async memberDelete( + timelineName: string, + username: string, + _token: string + ): Promise { + await mockPrepare(); + const oldMembers = + (await getTimelinePropertyValue( + timelineName, + 'members' + )) ?? []; + if (oldMembers.includes(username)) { + await setTimelinePropertyValue( + timelineName, + 'members', + without(oldMembers, username) + ); + } + } + + listPost( + timelineName: string, + token?: string + ): Promise; + listPost( + timelineName: string, + token: string | undefined, + query: { + modifiedSince?: Date; + includeDeleted?: false; + } + ): Promise; + listPost( + timelineName: string, + token: string | undefined, + query: { + modifiedSince?: Date; + includeDeleted: true; + } + ): Promise; + async listPost( + timelineName: string, + _token?: string, + query?: { + modifiedSince?: Date; + includeDeleted?: boolean; + } + ): Promise { + await mockPrepare(); + // TODO: Permission check. + + const currentPostId = await getTimelinePropertyValue( + 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; + async getPostData( + timelineName: string, + postId: number, + _token?: string, + etag?: string + ): Promise { + await mockPrepare(); + // TODO: Permission check. + + const optionalSavedEtag = await getTimelinePostPropertyValue( + timelineName, + postId, + 'etag' + ); + + if (optionalSavedEtag == null) { + const optionalType = await getTimelinePostPropertyValue( + 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( + timelineName, + postId, + 'data' + ), + etag: optionalSavedEtag, + }; + } + + async postPost( + timelineName: string, + req: HttpTimelinePostPostRequest, + token: string + ): Promise { + await mockPrepare(); + const user = checkToken(token); + + const savedId = await getTimelinePropertyValue( + 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 { + 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 { + 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; + } + throw e; + } + } + + async verify(req: HttpVerifyTokenRequest): Promise { + await mockPrepare(); + try { + const user = await getUser(checkToken(req.token)); + return { + user, + }; + } catch (e) { + throw { + isAxiosError: true, + response: { + status: 400, + }, + } as Partial; + } + } +} 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 { + if (_defaultAvatar == null) { + const blob = ( + await axios.get(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 { + checkUsername(username); + const savedNickname = await mockStorage.getItem( + `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 { + 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 { + await mockPrepare(); + if (req.nickname != null) { + await mockStorage.setItem(`user.${username}.nickname`, req.nickname); + } + return await getUser(username); + } + + getAvatar(username: string): Promise; + async getAvatar( + username: string, + etag?: string + ): Promise { + 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(`user.${username}.avatar.data`), + etag: await mockStorage.getItem(`user.${username}.avatar.etag`), + }; + } + + async putAvatar(username: string, data: Blob, _token: string): Promise { + await mockPrepare(); + const etag = await sha1(data); + await mockStorage.setItem(`user.${username}.avatar.data`, data); + await mockStorage.setItem(`user.${username}.avatar.etag`, etag); + } + + async changePassword( + _req: HttpChangePasswordRequest, + _token: string + ): Promise { + 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; + getTimeline(timelineName: string): Promise; + postTimeline( + req: HttpTimelinePostRequest, + token: string + ): Promise; + patchTimeline( + timelineName: string, + req: HttpTimelinePatchRequest, + token: string + ): Promise; + deleteTimeline(timelineName: string, token: string): Promise; + memberPut( + timelineName: string, + username: string, + token: string + ): Promise; + memberDelete( + timelineName: string, + username: string, + token: string + ): Promise; + listPost( + timelineName: string, + token?: string + ): Promise; + listPost( + timelineName: string, + token: string | undefined, + query: { + modifiedSince?: Date; + includeDeleted?: false; + } + ): Promise; + listPost( + timelineName: string, + token: string | undefined, + query: { + modifiedSince?: Date; + includeDeleted: true; + } + ): Promise; + getPostData( + timelineName: string, + postId: number, + token?: string + ): Promise; + getPostData( + timelineName: string, + postId: number, + token: string | undefined, + etag: string + ): Promise; + postPost( + timelineName: string, + req: HttpTimelinePostPostRequest, + token: string + ): Promise; + deletePost( + timelineName: string, + postId: number, + token: string + ): Promise; +} + +export class HttpTimelineClient implements IHttpTimelineClient { + listTimeline(query: HttpTimelineListQuery): Promise { + return axios + .get( + applyQueryParameters(`${apiBaseUrl}/timelines`, query) + ) + .then(extractResponseData) + .catch(convertToNetworkError); + } + + getTimeline(timelineName: string): Promise { + return axios + .get(`${apiBaseUrl}/timelines/${timelineName}`) + .then(extractResponseData) + .catch(convertToIfStatusCodeIs(404, HttpTimelineNotExistError)) + .catch(convertToNetworkError); + } + + postTimeline( + req: HttpTimelinePostRequest, + token: string + ): Promise { + return axios + .post(`${apiBaseUrl}/timelines?token=${token}`, req) + .then(extractResponseData) + .catch(convertToIfErrorCodeIs(11040101, HttpTimelineNameConflictError)) + .catch(convertToNetworkError); + } + + patchTimeline( + timelineName: string, + req: HttpTimelinePatchRequest, + token: string + ): Promise { + return axios + .patch( + `${apiBaseUrl}/timelines/${timelineName}?token=${token}`, + req + ) + .then(extractResponseData) + .catch(convertToNetworkError); + } + + deleteTimeline(timelineName: string, token: string): Promise { + return axios + .delete(`${apiBaseUrl}/timelines/${timelineName}?token=${token}`) + .catch(convertToNetworkError) + .then(); + } + + memberPut( + timelineName: string, + username: string, + token: string + ): Promise { + return axios + .put( + `${apiBaseUrl}/timelines/${timelineName}/members/${username}?token=${token}` + ) + .catch(convertToNetworkError) + .then(); + } + + memberDelete( + timelineName: string, + username: string, + token: string + ): Promise { + return axios + .delete( + `${apiBaseUrl}/timelines/${timelineName}/members/${username}?token=${token}` + ) + .catch(convertToNetworkError) + .then(); + } + + listPost( + timelineName: string, + token?: string + ): Promise; + listPost( + timelineName: string, + token: string | undefined, + query: { + modifiedSince?: Date; + includeDeleted?: false; + } + ): Promise; + listPost( + timelineName: string, + token: string | undefined, + query: { + modifiedSince?: Date; + includeDeleted: true; + } + ): Promise; + listPost( + timelineName: string, + token?: string, + query?: { + modifiedSince?: Date; + includeDeleted?: boolean; + } + ): Promise { + 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(url) + .then(extractResponseData) + .catch(convertToNetworkError) + .then((rawPosts) => + rawPosts.map((raw) => processRawTimelinePostInfo(raw)) + ); + } + + getPostData( + timelineName: string, + postId: number, + token: string + ): Promise; + getPostData( + timelineName: string, + postId: number, + token?: string, + etag?: string + ): Promise { + 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 { + 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( + `${apiBaseUrl}/timelines/${timelineName}/posts?token=${token}`, + rawReq + ) + .then(extractResponseData) + .catch(convertToNetworkError) + .then((rawPost) => processRawTimelinePostInfo(rawPost)); + } + + deletePost( + timelineName: string, + postId: number, + token: string + ): Promise { + 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; + verify(req: HttpVerifyTokenRequest): Promise; +} + +export class HttpTokenClient implements IHttpTokenClient { + create(req: HttpCreateTokenRequest): Promise { + return axios + .post(`${apiBaseUrl}/token/create`, req) + .then(extractResponseData) + .catch( + convertToIfErrorCodeIs(11010101, HttpCreateTokenBadCredentialError) + ) + .catch(convertToNetworkError); + } + + verify(req: HttpVerifyTokenRequest): Promise { + return axios + .post(`${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; + patch( + username: string, + req: HttpUserPatchRequest, + token: string + ): Promise; + getAvatar(username: string): Promise; + getAvatar( + username: string, + etag: string + ): Promise; + putAvatar(username: string, data: Blob, token: string): Promise; + changePassword(req: HttpChangePasswordRequest, token: string): Promise; +} + +export class HttpUserClient implements IHttpUserClient { + get(username: string): Promise { + return axios + .get(`${apiBaseUrl}/users/${username}`) + .then(extractResponseData) + .catch(convertToIfStatusCodeIs(404, HttpUserNotExistError)) + .catch(convertToNetworkError); + } + + patch( + username: string, + req: HttpUserPatchRequest, + token: string + ): Promise { + return axios + .patch(`${apiBaseUrl}/users/${username}?token=${token}`, req) + .then(extractResponseData) + .catch(convertToNetworkError); + } + + getAvatar(username: string): Promise; + getAvatar( + username: string, + etag?: string + ): Promise { + 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 { + return axios + .put(`${apiBaseUrl}/users/${username}/avatar?token=${token}`, data, { + headers: { + 'Content-Type': data.type, + }, + }) + .catch(convertToNetworkError) + .then(); + } + + changePassword(req: HttpChangePasswordRequest, token: string): Promise { + 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; +} -- cgit v1.2.3