Add StorageClient for api-client

Signed-off-by: Nikolay Marchuk <nikolay.marchuk@hardcoreeng.com>
This commit is contained in:
Nikolay Marchuk 2025-05-05 13:10:37 +07:00
parent 70dc3f4387
commit d2ee1fb838
7 changed files with 698 additions and 416 deletions

File diff suppressed because it is too large Load Diff

View File

@ -58,6 +58,7 @@
"@hcengineering/platform": "^0.6.11",
"@hcengineering/text": "^0.6.5",
"@hcengineering/text-markdown": "^0.6.0",
"form-data": "^4.0.0",
"snappyjs": "^0.7.0"
},
"repository": "https://github.com/hcengineering/platform",

View File

@ -20,3 +20,4 @@ export * from './types'
export * from './rest'
export * from './config'
export * from './utils'
export * from './storage'

View File

@ -0,0 +1,201 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import core, { concatLink, WorkspaceUuid, Blob, Ref } from '@hcengineering/core'
import FormData from 'form-data'
import { Readable } from 'stream'
import { StorageClient } from './types'
import { loadServerConfig, ServerConfig } from '../config'
import { NetworkError, NotFoundError, StorageError } from './error'
import { AuthOptions } from '../types'
import { getWorkspaceToken } from '../utils'
interface ObjectMetadata {
name: string
etag: string
size: number
contentType: string
lastModified: number
cacheControl?: string
}
interface BlobUploadSuccess {
key: string
id: string
metadata: ObjectMetadata
}
interface BlobUploadError {
key: string
error: string
}
type BlobUploadResult = BlobUploadSuccess | BlobUploadError
export class StorageClientImpl implements StorageClient {
private readonly headers: Record<string, string>
constructor (
readonly filesUrl: string,
readonly uploadUrl: string,
token: string,
readonly workspace: WorkspaceUuid
) {
this.headers = {
Authorization: 'Bearer ' + token
}
}
getObjectUrl (objectName: string): string {
return this.filesUrl.replace(':filename', objectName).replace(':blobId', objectName)
}
async stat (objectName: string): Promise<Blob | undefined> {
const url = this.getObjectUrl(objectName)
let response
try {
response = await wrappedFetch(url, { method: 'HEAD', headers: { ...this.headers } })
} catch (error: any) {
if (error instanceof NotFoundError) {
return
}
throw error
}
const headers = response.headers
const lastModified = Date.parse(headers.get('Last-Modified') ?? '')
const size = parseInt(headers.get('Content-Length') ?? '0', 10)
return {
provider: '',
_class: core.class.Blob,
_id: objectName as Ref<Blob>,
contentType: headers.get('Content-Type') ?? '',
size: isNaN(size) ? 0 : size ?? 0,
etag: headers.get('ETag') ?? '',
space: core.space.Configuration,
modifiedBy: core.account.System,
modifiedOn: isNaN(lastModified) ? 0 : lastModified,
version: null
}
}
async get (objectName: string): Promise<Readable> {
const url = this.getObjectUrl(objectName)
const response = await wrappedFetch(url, { headers: { ...this.headers } })
if (response.body == null) {
throw new StorageError('Missing response body')
}
return Readable.from(response.body)
}
async put (objectName: string, stream: Readable | Buffer | string, contentType: string, size?: number): Promise<Blob> {
const formData = new FormData()
const options: FormData.AppendOptions = {
filename: objectName,
contentType,
knownLength: size
}
formData.append('file', stream, options)
const response = await wrappedFetch(this.uploadUrl, {
method: 'POST',
body: Readable.toWeb(formData) as ReadableStream,
headers: { ...this.headers }
})
const result = (await response.json()) as BlobUploadResult[]
if (Object.hasOwn(result[0], 'id')) {
const fileResult = result[0] as BlobUploadSuccess
return {
_class: core.class.Blob,
_id: fileResult.id as Ref<Blob>,
space: core.space.Configuration,
modifiedOn: fileResult.metadata.lastModified,
modifiedBy: core.account.System,
provider: '',
contentType: fileResult.metadata.contentType,
etag: fileResult.metadata.etag,
version: null,
size: fileResult.metadata.size
}
} else {
const error = (result[0] as BlobUploadError) ?? 'Unknown error'
throw new StorageError(`Storage error ${error.error}`)
}
}
async partial (objectName: string, offset: number, length?: number): Promise<Readable> {
const url = this.getObjectUrl(objectName)
const response = await wrappedFetch(url, {
headers: {
...this.headers,
Range: length !== undefined ? `bytes=${offset}-${offset + length - 1}` : `bytes=${offset}`
}
})
if (response.body == null) {
throw new StorageError('Missing response body')
}
return Readable.from(response.body)
}
async remove (objectName: string): Promise<void> {
const url = this.getObjectUrl(objectName)
await wrappedFetch(url, {
method: 'DELETE',
headers: { ...this.headers }
})
}
}
async function wrappedFetch (url: string | URL, init?: RequestInit): Promise<Response> {
let response: Response
try {
response = await fetch(url, init)
} catch (error: any) {
throw new NetworkError(`Network error ${error}`)
}
if (!response.ok) {
const text = await response.text()
if (response.status === 404) {
throw new NotFoundError(text)
} else {
throw new StorageError(text)
}
}
return response
}
export function createStorageClient (
filesUrl: string,
uploadUrl: string,
token: string,
workspace: WorkspaceUuid
): StorageClient {
return new StorageClientImpl(filesUrl, uploadUrl, token, workspace)
}
export async function connectStorage (url: string, options: AuthOptions, config?: ServerConfig): Promise<StorageClient> {
config ??= await loadServerConfig(url)
const token = await getWorkspaceToken(url, options, config)
const filesUrl = (config.FILES_URL.startsWith('/') ? concatLink(url, config.FILES_URL) : config.FILES_URL).replace(
':workspace',
token.workspaceId
)
const uploadUrl = (config.UPLOAD_URL.startsWith('/') ? concatLink(url, config.UPLOAD_URL) : config.UPLOAD_URL).replace(
':workspace',
token.workspaceId
)
return new StorageClientImpl(filesUrl, uploadUrl, token.token, token.workspaceId)
}

View File

@ -0,0 +1,35 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
export class NetworkError extends Error {
constructor (message: string) {
super(message)
this.name = 'NetworkError'
}
}
export class StorageError extends Error {
constructor (message: string) {
super(message)
this.name = 'StorageError'
}
}
export class NotFoundError extends StorageError {
constructor (message = 'Not Found') {
super(message)
this.name = 'NotFoundError'
}
}

View File

@ -0,0 +1,18 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
export { createStorageClient, connectStorage } from './client'
export * from './error'
export * from './types'

View File

@ -0,0 +1,25 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { Blob } from '@hcengineering/core'
import { Readable } from 'stream'
export interface StorageClient {
stat: (objectName: string) => Promise<Blob | undefined>
get: (objectName: string) => Promise<Readable>
put: (objectName: string, stream: Readable | Buffer | string, contentType: string, size?: number) => Promise<Blob>
partial: (objectName: string, offset: number, length?: number) => Promise<Readable>
remove: (objectName: string) => Promise<void>
}