feat: add api package

This commit is contained in:
Karol Sójko
2022-07-06 11:53:39 +02:00
parent e2e9a11984
commit 9d7d18e7f2
48 changed files with 827 additions and 10 deletions

View File

@@ -0,0 +1,3 @@
export enum ApiVersion {
v0 = '20200115',
}

View File

@@ -0,0 +1 @@
export * from './ApiVersion'

View File

@@ -0,0 +1,77 @@
import { ProtocolVersion } from '@standardnotes/common'
import { RootKeyParamsInterface } from '@standardnotes/models'
import { UserRegistrationResponse } from '../../Response/User/UserRegistrationResponse'
import { UserServerInterface } from '../../Server'
import { UserApiService } from './UserApiService'
describe('UserApiService', () => {
let userServer: UserServerInterface
let keyParams: RootKeyParamsInterface
const createService = () => new UserApiService(userServer)
beforeEach(() => {
userServer = {} as jest.Mocked<UserServerInterface>
userServer.register = jest.fn().mockReturnValue({
data: { user: { email: 'test@test.te', uuid: '1-2-3' } },
} as jest.Mocked<UserRegistrationResponse>)
keyParams = {} as jest.Mocked<RootKeyParamsInterface>
keyParams.getPortableValue = jest.fn().mockReturnValue({
identifier: 'test@test.te',
version: ProtocolVersion.V004,
})
})
it('should register a user', async () => {
const response = await createService().register('test@test.te', 'testpasswd', keyParams, false)
expect(response).toEqual({
data: {
user: {
email: 'test@test.te',
uuid: '1-2-3',
},
},
})
expect(userServer.register).toHaveBeenCalledWith({
api: '20200115',
email: 'test@test.te',
ephemeral: false,
identifier: 'test@test.te',
password: 'testpasswd',
version: '004',
})
})
it('should not register a user if it is already registering', async () => {
const service = createService()
Object.defineProperty(service, 'registering', {
get: () => true,
})
let error = null
try {
await service.register('test@test.te', 'testpasswd', keyParams, false)
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
it('should not register a user if the server fails', async () => {
userServer.register = jest.fn().mockImplementation(() => {
throw new Error('Oops')
})
let error = null
try {
await createService().register('test@test.te', 'testpasswd', keyParams, false)
} catch (caughtError) {
error = caughtError
}
expect(error).not.toBeNull()
})
})

View File

@@ -0,0 +1,45 @@
import { RootKeyParamsInterface } from '@standardnotes/models'
import { ErrorMessage } from '../../Error/ErrorMessage'
import { ApiCallError } from '../../Error/ApiCallError'
import { UserRegistrationResponse } from '../../Response/User/UserRegistrationResponse'
import { UserServerInterface } from '../../Server/User/UserServerInterface'
import { ApiVersion } from '../../Api/ApiVersion'
import { ApiEndpointParam } from '../../Request/ApiEndpointParam'
import { UserApiServiceInterface } from './UserApiServiceInterface'
export class UserApiService implements UserApiServiceInterface {
private registering: boolean
constructor(private userServer: UserServerInterface) {
this.registering = false
}
async register(
email: string,
serverPassword: string,
keyParams: RootKeyParamsInterface,
ephemeral: boolean,
): Promise<UserRegistrationResponse> {
if (this.registering) {
throw new ApiCallError(ErrorMessage.RegistrationInProgress)
}
this.registering = true
try {
const response = await this.userServer.register({
[ApiEndpointParam.ApiVersion]: ApiVersion.v0,
password: serverPassword,
email,
ephemeral,
...keyParams.getPortableValue(),
})
this.registering = false
return response
} catch (error) {
throw new ApiCallError(ErrorMessage.GenericRegistrationFail)
}
}
}

View File

@@ -0,0 +1,11 @@
import { RootKeyParamsInterface } from '@standardnotes/models'
import { UserRegistrationResponse } from '../../Response/User/UserRegistrationResponse'
export interface UserApiServiceInterface {
register(
email: string,
serverPassword: string,
keyParams: RootKeyParamsInterface,
ephemeral: boolean,
): Promise<UserRegistrationResponse>
}

View File

@@ -0,0 +1,2 @@
export * from './User/UserApiService'
export * from './User/UserApiServiceInterface'

View File

@@ -0,0 +1,6 @@
export class ApiCallError extends Error {
constructor(message: string) {
super(message)
Object.setPrototypeOf(this, ApiCallError.prototype)
}
}

View File

@@ -0,0 +1,7 @@
export enum ErrorMessage {
RegistrationInProgress = 'An existing registration request is already in progress.',
GenericRegistrationFail = 'A server error occurred while trying to register. Please try again.',
RateLimited = 'Too many successive server requests. Please wait a few minutes and try again.',
InsufficientPasswordMessage = 'Your password must be at least %LENGTH% characters in length. For your security, please choose a longer password or, ideally, a passphrase, and try again.',
PasscodeRequired = 'Your passcode is required in order to register for an account.',
}

View File

@@ -0,0 +1,2 @@
export * from './ApiCallError'
export * from './ErrorMessage'

View File

@@ -0,0 +1,11 @@
export enum ErrorTag {
MfaInvalid = 'mfa-invalid',
MfaRequired = 'mfa-required',
RefreshTokenInvalid = 'invalid-refresh-token',
RefreshTokenExpired = 'expired-refresh-token',
AccessTokenExpired = 'expired-access-token',
ParametersInvalid = 'invalid-parameters',
RevokedSession = 'revoked-session',
AuthInvalid = 'invalid-auth',
ReadOnlyAccess = 'read-only-access',
}

View File

@@ -0,0 +1,8 @@
import { ErrorTag } from './ErrorTag'
export type HttpErrorResponseBody = {
error: {
message: string
tag?: ErrorTag
}
}

View File

@@ -0,0 +1 @@
export type HttpHeaders = Map<string, string | null>

View File

@@ -0,0 +1,13 @@
import { HttpRequestParams } from './HttpRequestParams'
import { HttpVerb } from './HttpVerb'
export type HttpRequest = {
url: string
params?: HttpRequestParams
rawBytes?: Uint8Array
verb: HttpVerb
authentication?: string
customHeaders?: Record<string, string>[]
responseType?: XMLHttpRequestResponseType
external?: boolean
}

View File

@@ -0,0 +1 @@
export type HttpRequestParams = Record<string, unknown>

View File

@@ -0,0 +1,12 @@
import { HttpStatusCode } from './HttpStatusCode'
import { HttpResponseBody } from './HttpResponseBody'
import { HttpErrorResponseBody } from './HttpErrorResponseBody'
import { HttpResponseMeta } from './HttpResponseMeta'
import { HttpHeaders } from './HttpHeaders'
export interface HttpResponse {
status: HttpStatusCode
data?: HttpResponseBody | HttpErrorResponseBody
meta?: HttpResponseMeta
headers?: HttpHeaders
}

View File

@@ -0,0 +1 @@
export type HttpResponseBody = Record<string, unknown>

View File

@@ -0,0 +1,12 @@
import { Role } from '@standardnotes/security'
import { Uuid } from '@standardnotes/common'
export type HttpResponseMeta = {
auth: {
userUuid?: Uuid
roles?: Role[]
}
server: {
filesServerUrl?: string
}
}

View File

@@ -0,0 +1,27 @@
import { Environment } from '@standardnotes/services'
import { HttpResponseMeta } from './HttpResponseMeta'
import { HttpService } from './HttpService'
describe('HttpService', () => {
const environment = Environment.Web
const appVersion = '1.2.3'
const snjsVersion = '2.3.4'
const host = 'http://bar'
let updateMetaCallback: (meta: HttpResponseMeta) => void
const createService = () => new HttpService(environment, appVersion, snjsVersion, host, updateMetaCallback)
beforeEach(() => {
updateMetaCallback = jest.fn()
})
it('should set host', () => {
const service = createService()
expect(service['host']).toEqual('http://bar')
service.setHost('http://foo')
expect(service['host']).toEqual('http://foo')
})
})

View File

@@ -0,0 +1,190 @@
import { isString, joinPaths } from '@standardnotes/utils'
import { Environment } from '@standardnotes/services'
import { HttpRequestParams } from './HttpRequestParams'
import { HttpVerb } from './HttpVerb'
import { HttpRequest } from './HttpRequest'
import { HttpResponse } from './HttpResponse'
import { HttpServiceInterface } from './HttpServiceInterface'
import { HttpStatusCode } from './HttpStatusCode'
import { XMLHttpRequestState } from './XMLHttpRequestState'
import { ErrorMessage } from '../Error/ErrorMessage'
import { HttpResponseMeta } from './HttpResponseMeta'
import { HttpErrorResponseBody } from './HttpErrorResponseBody'
export class HttpService implements HttpServiceInterface {
constructor(
private environment: Environment,
private appVersion: string,
private snjsVersion: string,
private host: string,
private updateMetaCallback: (meta: HttpResponseMeta) => void,
) {}
setHost(host: string): void {
this.host = host
}
async get(path: string, params?: HttpRequestParams, authentication?: string): Promise<HttpResponse> {
return this.runHttp({ url: joinPaths(this.host, path), params, verb: HttpVerb.Get, authentication })
}
async post(path: string, params?: HttpRequestParams, authentication?: string): Promise<HttpResponse> {
return this.runHttp({ url: joinPaths(this.host, path), params, verb: HttpVerb.Post, authentication })
}
async put(path: string, params?: HttpRequestParams, authentication?: string): Promise<HttpResponse> {
return this.runHttp({ url: joinPaths(this.host, path), params, verb: HttpVerb.Put, authentication })
}
async patch(path: string, params: HttpRequestParams, authentication?: string): Promise<HttpResponse> {
return this.runHttp({ url: joinPaths(this.host, path), params, verb: HttpVerb.Patch, authentication })
}
async delete(path: string, params?: HttpRequestParams, authentication?: string): Promise<HttpResponse> {
return this.runHttp({ url: joinPaths(this.host, path), params, verb: HttpVerb.Delete, authentication })
}
private async runHttp(httpRequest: HttpRequest): Promise<HttpResponse> {
const request = this.createXmlRequest(httpRequest)
const response = await this.runRequest(request, this.createRequestBody(httpRequest))
if (response.meta) {
this.updateMetaCallback(response.meta)
}
return response
}
private createRequestBody(httpRequest: HttpRequest): string | Uint8Array | undefined {
if (
httpRequest.params !== undefined &&
[HttpVerb.Post, HttpVerb.Put, HttpVerb.Patch, HttpVerb.Delete].includes(httpRequest.verb)
) {
return JSON.stringify(httpRequest.params)
}
return httpRequest.rawBytes
}
private createXmlRequest(httpRequest: HttpRequest) {
const request = new XMLHttpRequest()
if (httpRequest.params && httpRequest.verb === HttpVerb.Get && Object.keys(httpRequest.params).length > 0) {
httpRequest.url = this.urlForUrlAndParams(httpRequest.url, httpRequest.params)
}
request.open(httpRequest.verb, httpRequest.url, true)
request.responseType = httpRequest.responseType ?? ''
if (!httpRequest.external) {
request.setRequestHeader('X-SNJS-Version', this.snjsVersion)
const appVersionHeaderValue = `${Environment[this.environment]}-${this.appVersion}`
request.setRequestHeader('X-Application-Version', appVersionHeaderValue)
if (httpRequest.authentication) {
request.setRequestHeader('Authorization', 'Bearer ' + httpRequest.authentication)
}
}
let contenTypeIsSet = false
if (httpRequest.customHeaders && httpRequest.customHeaders.length > 0) {
httpRequest.customHeaders.forEach(({ key, value }) => {
request.setRequestHeader(key, value)
if (key === 'Content-Type') {
contenTypeIsSet = true
}
})
}
if (!contenTypeIsSet && !httpRequest.external) {
request.setRequestHeader('Content-Type', 'application/json')
}
return request
}
private async runRequest(request: XMLHttpRequest, body?: string | Uint8Array): Promise<HttpResponse> {
return new Promise((resolve, reject) => {
request.onreadystatechange = () => {
this.stateChangeHandlerForRequest(request, resolve, reject)
}
request.send(body)
})
}
private stateChangeHandlerForRequest(
request: XMLHttpRequest,
resolve: (response: HttpResponse) => void,
reject: (response: HttpResponse) => void,
) {
if (request.readyState !== XMLHttpRequestState.Completed) {
return
}
const httpStatus = request.status
const response: HttpResponse = {
status: httpStatus,
headers: new Map<string, string | null>(),
}
const responseHeaderLines = request
.getAllResponseHeaders()
?.trim()
.split(/[\r\n]+/)
responseHeaderLines?.forEach((responseHeaderLine) => {
const parts = responseHeaderLine.split(': ')
const name = parts.shift() as string
const value = parts.join(': ')
;(<Map<string, string | null>>response.headers).set(name, value)
})
try {
if (httpStatus !== HttpStatusCode.NoContent) {
let body
const contentTypeHeader = response.headers?.get('content-type') || response.headers?.get('Content-Type')
if (contentTypeHeader?.includes('application/json')) {
body = JSON.parse(request.responseText)
} else {
body = request.response
}
/**
* v0 APIs do not have a `data` top-level object. In such cases, mimic
* the newer response body style by putting all the top-level
* properties inside a `data` object.
*/
if (!body.data) {
response.data = body
}
if (!isString(body)) {
Object.assign(response, body)
}
}
} catch (error) {
console.error(error)
}
if (httpStatus >= HttpStatusCode.Success && httpStatus < HttpStatusCode.MultipleChoices) {
resolve(response)
} else {
if (httpStatus === HttpStatusCode.Forbidden && response.data && response.data.error !== undefined) {
;(response.data as HttpErrorResponseBody).error.message = ErrorMessage.RateLimited
}
reject(response)
}
}
private urlForUrlAndParams(url: string, params: HttpRequestParams) {
const keyValueString = Object.keys(params)
.map((key) => {
return key + '=' + encodeURIComponent(params[key] as string)
})
.join('&')
if (url.includes('?')) {
return url + '&' + keyValueString
} else {
return url + '?' + keyValueString
}
}
}

View File

@@ -0,0 +1,11 @@
import { HttpRequestParams } from './HttpRequestParams'
import { HttpResponse } from './HttpResponse'
export interface HttpServiceInterface {
setHost(host: string): void
get(path: string, params?: HttpRequestParams, authentication?: string): Promise<HttpResponse>
post(path: string, params?: HttpRequestParams, authentication?: string): Promise<HttpResponse>
put(path: string, params?: HttpRequestParams, authentication?: string): Promise<HttpResponse>
patch(path: string, params: HttpRequestParams, authentication?: string): Promise<HttpResponse>
delete(path: string, params?: HttpRequestParams, authentication?: string): Promise<HttpResponse>
}

View File

@@ -0,0 +1,9 @@
export enum HttpStatusCode {
Success = 200,
NoContent = 204,
MultipleChoices = 300,
BadRequest = 400,
Unauthorized = 401,
Forbidden = 403,
ExpiredAccessToken = 498,
}

View File

@@ -0,0 +1,7 @@
export enum HttpVerb {
Get = 'GET',
Post = 'POST',
Put = 'PUT',
Patch = 'PATCH',
Delete = 'DELETE',
}

View File

@@ -0,0 +1,3 @@
export enum XMLHttpRequestState {
Completed = 4,
}

View File

@@ -0,0 +1,13 @@
export * from './ErrorTag'
export * from './HttpErrorResponseBody'
export * from './HttpHeaders'
export * from './HttpRequest'
export * from './HttpRequestParams'
export * from './HttpResponse'
export * from './HttpResponseBody'
export * from './HttpResponseMeta'
export * from './HttpService'
export * from './HttpServiceInterface'
export * from './HttpStatusCode'
export * from './HttpVerb'
export * from './XMLHttpRequestState'

View File

@@ -0,0 +1,7 @@
export enum ApiEndpointParam {
LastSyncToken = 'sync_token',
PaginationToken = 'cursor_token',
SyncDlLimit = 'limit',
SyncPayloads = 'items',
ApiVersion = 'api',
}

View File

@@ -0,0 +1,11 @@
import { AnyKeyParamsContent } from '@standardnotes/common'
import { ApiEndpointParam } from '../ApiEndpointParam'
import { ApiVersion } from '../../Api/ApiVersion'
export type UserRegistrationRequestParams = AnyKeyParamsContent & {
[ApiEndpointParam.ApiVersion]: ApiVersion.v0
password: string
email: string
ephemeral: boolean
[additionalParam: string]: unknown
}

View File

@@ -0,0 +1,2 @@
export * from './ApiEndpointParam'
export * from './User/UserRegistrationRequestParams'

View File

@@ -0,0 +1,7 @@
import { HttpErrorResponseBody } from '../../Http/HttpErrorResponseBody'
import { HttpResponse } from '../../Http/HttpResponse'
import { UserRegistrationResponseBody } from './UserRegistrationResponseBody'
export interface UserRegistrationResponse extends HttpResponse {
data: UserRegistrationResponseBody | HttpErrorResponseBody
}

View File

@@ -0,0 +1,11 @@
import { Uuid } from '@standardnotes/common'
import { KeyParamsData, SessionBody } from '@standardnotes/responses'
export type UserRegistrationResponseBody = {
session: SessionBody
key_params: KeyParamsData
user: {
uuid: Uuid
email: string
}
}

View File

@@ -0,0 +1,2 @@
export * from './User/UserRegistrationResponse'
export * from './User/UserRegistrationResponseBody'

View File

@@ -0,0 +1,9 @@
const UserPaths = {
register: '/v1/users',
}
export const Paths = {
v1: {
...UserPaths,
},
}

View File

@@ -0,0 +1,39 @@
import { ProtocolVersion } from '@standardnotes/common'
import { ApiVersion } from '../../Api'
import { HttpServiceInterface } from '../../Http'
import { UserRegistrationResponse } from '../../Response'
import { UserServer } from './UserServer'
describe('UserServer', () => {
let httpService: HttpServiceInterface
const createServer = () => new UserServer(httpService)
beforeEach(() => {
httpService = {} as jest.Mocked<HttpServiceInterface>
httpService.post = jest.fn().mockReturnValue({
data: { user: { email: 'test@test.te', uuid: '1-2-3' } },
} as jest.Mocked<UserRegistrationResponse>)
})
it('should register a user', async () => {
const response = await createServer().register({
password: 'test',
api: ApiVersion.v0,
email: 'test@test.te',
ephemeral: false,
version: ProtocolVersion.V004,
pw_nonce: 'test',
identifier: 'test@test.te',
})
expect(response).toEqual({
data: {
user: {
email: 'test@test.te',
uuid: '1-2-3',
},
},
})
})
})

View File

@@ -0,0 +1,15 @@
import { HttpServiceInterface } from '../../Http/HttpServiceInterface'
import { UserRegistrationRequestParams } from '../../Request/User/UserRegistrationRequestParams'
import { UserRegistrationResponse } from '../../Response/User/UserRegistrationResponse'
import { Paths } from './Paths'
import { UserServerInterface } from './UserServerInterface'
export class UserServer implements UserServerInterface {
constructor(private httpService: HttpServiceInterface) {}
async register(params: UserRegistrationRequestParams): Promise<UserRegistrationResponse> {
const response = await this.httpService.post(Paths.v1.register, params)
return response as UserRegistrationResponse
}
}

View File

@@ -0,0 +1,6 @@
import { UserRegistrationRequestParams } from '../../Request/User/UserRegistrationRequestParams'
import { UserRegistrationResponse } from '../../Response/User/UserRegistrationResponse'
export interface UserServerInterface {
register(params: UserRegistrationRequestParams): Promise<UserRegistrationResponse>
}

View File

@@ -0,0 +1,3 @@
export * from './User/Paths'
export * from './User/UserServer'
export * from './User/UserServerInterface'

View File

@@ -0,0 +1,7 @@
export * from './Api'
export * from './Client'
export * from './Error'
export * from './Http'
export * from './Request'
export * from './Response'
export * from './Server'

View File

@@ -0,0 +1 @@
export * from './Domain'