feat: add api package
This commit is contained in:
11
packages/api/src/Domain/Http/ErrorTag.ts
Normal file
11
packages/api/src/Domain/Http/ErrorTag.ts
Normal 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',
|
||||
}
|
||||
8
packages/api/src/Domain/Http/HttpErrorResponseBody.ts
Normal file
8
packages/api/src/Domain/Http/HttpErrorResponseBody.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { ErrorTag } from './ErrorTag'
|
||||
|
||||
export type HttpErrorResponseBody = {
|
||||
error: {
|
||||
message: string
|
||||
tag?: ErrorTag
|
||||
}
|
||||
}
|
||||
1
packages/api/src/Domain/Http/HttpHeaders.ts
Normal file
1
packages/api/src/Domain/Http/HttpHeaders.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type HttpHeaders = Map<string, string | null>
|
||||
13
packages/api/src/Domain/Http/HttpRequest.ts
Normal file
13
packages/api/src/Domain/Http/HttpRequest.ts
Normal 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
|
||||
}
|
||||
1
packages/api/src/Domain/Http/HttpRequestParams.ts
Normal file
1
packages/api/src/Domain/Http/HttpRequestParams.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type HttpRequestParams = Record<string, unknown>
|
||||
12
packages/api/src/Domain/Http/HttpResponse.ts
Normal file
12
packages/api/src/Domain/Http/HttpResponse.ts
Normal 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
|
||||
}
|
||||
1
packages/api/src/Domain/Http/HttpResponseBody.ts
Normal file
1
packages/api/src/Domain/Http/HttpResponseBody.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type HttpResponseBody = Record<string, unknown>
|
||||
12
packages/api/src/Domain/Http/HttpResponseMeta.ts
Normal file
12
packages/api/src/Domain/Http/HttpResponseMeta.ts
Normal 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
|
||||
}
|
||||
}
|
||||
27
packages/api/src/Domain/Http/HttpService.spec.ts
Normal file
27
packages/api/src/Domain/Http/HttpService.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
190
packages/api/src/Domain/Http/HttpService.ts
Normal file
190
packages/api/src/Domain/Http/HttpService.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
11
packages/api/src/Domain/Http/HttpServiceInterface.ts
Normal file
11
packages/api/src/Domain/Http/HttpServiceInterface.ts
Normal 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>
|
||||
}
|
||||
9
packages/api/src/Domain/Http/HttpStatusCode.ts
Normal file
9
packages/api/src/Domain/Http/HttpStatusCode.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export enum HttpStatusCode {
|
||||
Success = 200,
|
||||
NoContent = 204,
|
||||
MultipleChoices = 300,
|
||||
BadRequest = 400,
|
||||
Unauthorized = 401,
|
||||
Forbidden = 403,
|
||||
ExpiredAccessToken = 498,
|
||||
}
|
||||
7
packages/api/src/Domain/Http/HttpVerb.ts
Normal file
7
packages/api/src/Domain/Http/HttpVerb.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export enum HttpVerb {
|
||||
Get = 'GET',
|
||||
Post = 'POST',
|
||||
Put = 'PUT',
|
||||
Patch = 'PATCH',
|
||||
Delete = 'DELETE',
|
||||
}
|
||||
3
packages/api/src/Domain/Http/XMLHttpRequestState.ts
Normal file
3
packages/api/src/Domain/Http/XMLHttpRequestState.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export enum XMLHttpRequestState {
|
||||
Completed = 4,
|
||||
}
|
||||
13
packages/api/src/Domain/Http/index.ts
Normal file
13
packages/api/src/Domain/Http/index.ts
Normal 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'
|
||||
Reference in New Issue
Block a user