UBERF-9513: Support model operations (#8100)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2025-02-26 22:44:48 +07:00 committed by GitHub
parent 9c460d6388
commit 615a088ed0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 981 additions and 203 deletions

View File

@ -485,6 +485,11 @@ jobs:
cd ./ws-tests cd ./ws-tests
export DO_CLEAN=true export DO_CLEAN=true
./prepare.sh ./prepare.sh
- name: Run API tests
run: |
cd ./ws-tests/api-tests
node ../../common/scripts/install-run-rush.js validate --to @hcengineering/api-tests
node ../../common/scripts/install-run-rushx.js api-test --verbose
- name: Install Playwright - name: Install Playwright
run: | run: |
cd ./ws-tests/sanity cd ./ws-tests/sanity

View File

@ -106,6 +106,9 @@ importers:
'@rush-temp/api-client': '@rush-temp/api-client':
specifier: file:./projects/api-client.tgz specifier: file:./projects/api-client.tgz
version: file:projects/api-client.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(esbuild@0.24.2)(utf-8-validate@6.0.4) version: file:projects/api-client.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(esbuild@0.24.2)(utf-8-validate@6.0.4)
'@rush-temp/api-tests':
specifier: file:./projects/api-tests.tgz
version: file:projects/api-tests.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))
'@rush-temp/attachment': '@rush-temp/attachment':
specifier: file:./projects/attachment.tgz specifier: file:./projects/attachment.tgz
version: file:projects/attachment.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@20.11.19)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)) version: file:projects/attachment.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@20.11.19)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))
@ -1095,7 +1098,7 @@ importers:
version: file:projects/tests-sanity.tgz version: file:projects/tests-sanity.tgz
'@rush-temp/tests-ws-sanity': '@rush-temp/tests-ws-sanity':
specifier: file:./projects/tests-ws-sanity.tgz specifier: file:./projects/tests-ws-sanity.tgz
version: file:projects/tests-ws-sanity.tgz version: file:projects/tests-ws-sanity.tgz(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))
'@rush-temp/text': '@rush-temp/text':
specifier: file:./projects/text.tgz specifier: file:./projects/text.tgz
version: file:projects/text.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@20.11.19)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(esbuild@0.24.2)(prosemirror-inputrules@1.4.0)(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))(utf-8-validate@6.0.4) version: file:projects/text.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@20.11.19)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(esbuild@0.24.2)(prosemirror-inputrules@1.4.0)(prosemirror-model@1.24.1)(prosemirror-state@1.4.3)(prosemirror-view@1.37.2)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))(utf-8-validate@6.0.4)
@ -3864,6 +3867,10 @@ packages:
resolution: {integrity: sha512-gq68pTrBheqcmVrkbiqYY+6p80DQHIsJvC1nIvhRfwrj3eR1ir5fVsW5t2Gs8fvN/Um8nH0MnkTUGavVrPWIdw==, tarball: file:projects/api-client.tgz} resolution: {integrity: sha512-gq68pTrBheqcmVrkbiqYY+6p80DQHIsJvC1nIvhRfwrj3eR1ir5fVsW5t2Gs8fvN/Um8nH0MnkTUGavVrPWIdw==, tarball: file:projects/api-client.tgz}
version: 0.0.0 version: 0.0.0
'@rush-temp/api-tests@file:projects/api-tests.tgz':
resolution: {integrity: sha512-gCHsoeILAG/lcLoL1r7XjxDLqdEcEMphk+FU2tHcqqXolwuytD2Rnd9Ko+IVta8kGBTMwCk8DiX9qdICHL7NbQ==, tarball: file:projects/api-tests.tgz}
version: 0.0.0
'@rush-temp/attachment-assets@file:projects/attachment-assets.tgz': '@rush-temp/attachment-assets@file:projects/attachment-assets.tgz':
resolution: {integrity: sha512-C4ZvrB9y7H0bh1vbkqmKw9tIrwFAHUG7P6kPI+tjRPRKQNGAn61eiVSTpM8BCGNY/mFC5b+uMy0us0UaqvPy+A==, tarball: file:projects/attachment-assets.tgz} resolution: {integrity: sha512-C4ZvrB9y7H0bh1vbkqmKw9tIrwFAHUG7P6kPI+tjRPRKQNGAn61eiVSTpM8BCGNY/mFC5b+uMy0us0UaqvPy+A==, tarball: file:projects/attachment-assets.tgz}
version: 0.0.0 version: 0.0.0
@ -4589,7 +4596,7 @@ packages:
version: 0.0.0 version: 0.0.0
'@rush-temp/pod-calendar@file:projects/pod-calendar.tgz': '@rush-temp/pod-calendar@file:projects/pod-calendar.tgz':
resolution: {integrity: sha512-BvN38ScXSeB5Mui24Sawekl81QZMFZ47EK6KdIqRJJQwM+Qj0owWul3y/r7ZtNAfKnD9bV3AeEWaqkZ8EXYjwg==, tarball: file:projects/pod-calendar.tgz} resolution: {integrity: sha512-gUk3jshnHJ0CUucxZ1Stk8oQ2DTdSVfJzFHIl/EBUISbRv/YySoXesQCnqfn1kVpqOBZdIvI/N4NRAL/yREbWw==, tarball: file:projects/pod-calendar.tgz}
version: 0.0.0 version: 0.0.0
'@rush-temp/pod-collaborator@file:projects/pod-collaborator.tgz': '@rush-temp/pod-collaborator@file:projects/pod-collaborator.tgz':
@ -5065,7 +5072,7 @@ packages:
version: 0.0.0 version: 0.0.0
'@rush-temp/server-ws@file:projects/server-ws.tgz': '@rush-temp/server-ws@file:projects/server-ws.tgz':
resolution: {integrity: sha512-3jjoKDZ0NAs4XRajHH6n4EgS00GwQY0Zga1JlG8XIeSPjAGphGUYbCaHQuJbcddPAYRdT632gK+119U4RvGSxw==, tarball: file:projects/server-ws.tgz} resolution: {integrity: sha512-kVd/j4HPgbCYMMEqEkxRQiGqM0es47rgzzPdQvTDEFs6HtkyWxy8pbzRqA6HXJIDbNY5WLeTbtiLV37db6q33A==, tarball: file:projects/server-ws.tgz}
version: 0.0.0 version: 0.0.0
'@rush-temp/server@file:projects/server.tgz': '@rush-temp/server@file:projects/server.tgz':
@ -5181,7 +5188,7 @@ packages:
version: 0.0.0 version: 0.0.0
'@rush-temp/tests-ws-sanity@file:projects/tests-ws-sanity.tgz': '@rush-temp/tests-ws-sanity@file:projects/tests-ws-sanity.tgz':
resolution: {integrity: sha512-2y+gKxse3ozzKeoi1TF7Qfv7Y1fWy9Z6HiqSsEeQgGPpv396cH7ZfpSJ/qwsxO66c6iq8LUuBtzZ4AlquHfG3w==, tarball: file:projects/tests-ws-sanity.tgz} resolution: {integrity: sha512-YLGIPrvHv+48mtZrd5T8uJpGCkoGXjBtuxMvcD+Q3CNbHGTcYLxlznkmlNXa6wPm0G6v2VrpRuOfXAuAqlYnHg==, tarball: file:projects/tests-ws-sanity.tgz}
version: 0.0.0 version: 0.0.0
'@rush-temp/text-core@file:projects/text-core.tgz': '@rush-temp/text-core@file:projects/text-core.tgz':
@ -16161,6 +16168,41 @@ snapshots:
- supports-color - supports-color
- utf-8-validate - utf-8-validate
'@rush-temp/api-tests@file:projects/api-tests.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))':
dependencies:
'@types/body-parser': 1.19.5
'@types/compression': 1.7.5
'@types/cors': 2.8.17
'@types/express': 4.17.21
'@types/jest': 29.5.12
'@types/morgan': 1.9.9
'@types/node': 20.11.19
'@types/ws': 8.5.11
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.7.3)
'@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.7.3)
bufferutil: 4.0.8
eslint: 8.56.0
eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.3.3))(eslint-plugin-import@2.29.1(eslint@8.56.0))(eslint-plugin-n@15.7.0(eslint@8.56.0))(eslint-plugin-promise@6.1.1(eslint@8.56.0))(eslint@8.56.0)(typescript@5.7.3)
eslint-plugin-import: 2.29.1(eslint@8.56.0)
eslint-plugin-n: 15.7.0(eslint@8.56.0)
eslint-plugin-promise: 6.1.1(eslint@8.56.0)
jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))
prettier: 3.2.5
snappyjs: 0.7.0
ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(jest@29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)))(typescript@5.7.3)
typescript: 5.7.3
utf-8-validate: 6.0.4
ws: 8.18.0(bufferutil@4.0.8)(utf-8-validate@6.0.4)
transitivePeerDependencies:
- '@babel/core'
- '@jest/types'
- babel-jest
- babel-plugin-macros
- esbuild
- node-notifier
- supports-color
- ts-node
'@rush-temp/attachment-assets@file:projects/attachment-assets.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))': '@rush-temp/attachment-assets@file:projects/attachment-assets.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))':
dependencies: dependencies:
'@types/jest': 29.5.12 '@types/jest': 29.5.12
@ -20588,7 +20630,6 @@ snapshots:
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.3.3) '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.3.3)
'@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3) '@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.3.3)
cors: 2.8.5 cors: 2.8.5
cross-env: 7.0.3
dotenv: 16.0.3 dotenv: 16.0.3
esbuild: 0.24.2 esbuild: 0.24.2
eslint: 8.56.0 eslint: 8.56.0
@ -24085,6 +24126,7 @@ snapshots:
'@types/cors': 2.8.17 '@types/cors': 2.8.17
'@types/express': 4.17.21 '@types/express': 4.17.21
'@types/jest': 29.5.12 '@types/jest': 29.5.12
'@types/morgan': 1.9.9
'@types/node': 20.11.19 '@types/node': 20.11.19
'@types/ws': 8.5.11 '@types/ws': 8.5.11
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.3.3) '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.3.3))(eslint@8.56.0)(typescript@5.3.3)
@ -24099,6 +24141,7 @@ snapshots:
eslint-plugin-promise: 6.1.1(eslint@8.56.0) eslint-plugin-promise: 6.1.1(eslint@8.56.0)
express: 4.21.2 express: 4.21.2
jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)) jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))
morgan: 1.10.0
prettier: 3.2.5 prettier: 3.2.5
snappy: 7.2.2 snappy: 7.2.2
ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(jest@29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)))(typescript@5.3.3) ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(jest@29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3)))(typescript@5.3.3)
@ -24939,7 +24982,7 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@rush-temp/tests-ws-sanity@file:projects/tests-ws-sanity.tgz': '@rush-temp/tests-ws-sanity@file:projects/tests-ws-sanity.tgz(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))':
dependencies: dependencies:
'@faker-js/faker': 8.4.1 '@faker-js/faker': 8.4.1
'@playwright/test': 1.49.1 '@playwright/test': 1.49.1
@ -24956,10 +24999,14 @@ snapshots:
eslint-plugin-import: 2.29.1(eslint@8.56.0) eslint-plugin-import: 2.29.1(eslint@8.56.0)
eslint-plugin-n: 15.7.0(eslint@8.56.0) eslint-plugin-n: 15.7.0(eslint@8.56.0)
eslint-plugin-promise: 6.1.1(eslint@8.56.0) eslint-plugin-promise: 6.1.1(eslint@8.56.0)
jest: 29.7.0(@types/node@20.11.19)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))
prettier: 3.2.5 prettier: 3.2.5
typescript: 5.7.3 typescript: 5.7.3
transitivePeerDependencies: transitivePeerDependencies:
- babel-plugin-macros
- node-notifier
- supports-color - supports-color
- ts-node
'@rush-temp/text-core@file:projects/text-core.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@20.11.19)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(esbuild@0.24.2)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))(utf-8-validate@6.0.4)': '@rush-temp/text-core@file:projects/text-core.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@20.11.19)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(esbuild@0.24.2)(ts-node@10.9.2(@types/node@20.11.19)(typescript@5.3.3))(utf-8-validate@6.0.4)':
dependencies: dependencies:

View File

@ -13,6 +13,7 @@
// limitations under the License. // limitations under the License.
// //
import client, { clientId } from '@hcengineering/client'
import { import {
type Account, type Account,
type Class, type Class,
@ -26,18 +27,17 @@ import {
type ModelDb, type ModelDb,
type Ref, type Ref,
type Space, type Space,
type WithLookup,
type TxResult, type TxResult,
DocumentUpdate, type WithLookup,
TxOperations,
AttachedDoc,
AttachedData, AttachedData,
AttachedDoc,
DocumentUpdate,
Mixin, Mixin,
MixinUpdate,
MixinData, MixinData,
MixinUpdate,
TxOperations,
generateId generateId
} from '@hcengineering/core' } from '@hcengineering/core'
import client, { clientId } from '@hcengineering/client'
import { addLocation, getResource } from '@hcengineering/platform' import { addLocation, getResource } from '@hcengineering/platform'
import { login, selectWorkspace } from './account' import { login, selectWorkspace } from './account'
@ -49,7 +49,7 @@ import {
MarkupContent, MarkupContent,
createMarkupOperations createMarkupOperations
} from './markup' } from './markup'
import { type PlatformClient, type ConnectOptions, WithMarkup } from './types' import { type ConnectOptions, type PlatformClient, WithMarkup } from './types'
/** /**
* Create platform client * Create platform client
@ -278,11 +278,17 @@ class PlatformClientImpl implements PlatformClient {
} }
} }
async function getWorkspaceToken ( export interface WorkspaceToken {
endpoint: string
token: string
workspaceId: string
}
export async function getWorkspaceToken (
url: string, url: string,
options: ConnectOptions, options: ConnectOptions,
config?: ServerConfig config?: ServerConfig
): Promise<{ endpoint: string, token: string, workspaceId: string }> { ): Promise<WorkspaceToken> {
config ??= await loadServerConfig(url) config ??= await loadServerConfig(url)
let token: string let token: string

View File

@ -1,130 +0,0 @@
//
// 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 {
type Account,
type Class,
type Doc,
type DocumentQuery,
type FindOptions,
type FindResult,
type Ref,
type Storage,
type Tx,
type TxResult,
type WithLookup,
concatLink
} from '@hcengineering/core'
import { PlatformError, unknownError } from '@hcengineering/platform'
import { uncompress } from 'snappyjs'
export interface RestClient extends Storage {
getAccount: () => Promise<Account>
findOne: <T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
) => Promise<WithLookup<T> | undefined>
}
export async function createRestClient (endpoint: string, workspaceId: string, token: string): Promise<RestClient> {
return new RestClientImpl(endpoint, workspaceId, token)
}
class RestClientImpl implements RestClient {
constructor (
readonly endpoint: string,
readonly workspace: string,
readonly token: string
) {}
async findAll<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<FindResult<T>> {
const params = new URLSearchParams()
params.append('class', _class)
if (query !== undefined && Object.keys(query).length > 0) {
params.append('query', JSON.stringify(query))
}
if (options !== undefined && Object.keys(options).length > 0) {
params.append('options', JSON.stringify(options))
}
const response = await fetch(concatLink(this.endpoint, `/api/v1/find-all/${this.workspace}?${params.toString()}`), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + this.token,
'accept-encoding': 'snappy, gzip'
},
keepalive: true
})
if (!response.ok) {
throw new PlatformError(unknownError(response.statusText))
}
const encoding = response.headers.get('content-encoding')
if (encoding === 'snappy') {
const buffer = await response.arrayBuffer()
const decompressed = uncompress(buffer)
const decoder = new TextDecoder()
const jsonString = decoder.decode(decompressed)
return JSON.parse(jsonString) as FindResult<T>
}
return (await response.json()) as FindResult<T>
}
async getAccount (): Promise<Account> {
const response = await fetch(concatLink(this.endpoint, `/api/v1/account/${this.workspace}`), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + this.token
},
keepalive: true
})
if (!response.ok) {
throw new PlatformError(unknownError(response.statusText))
}
return (await response.json()) as Account
}
async findOne<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<WithLookup<T> | undefined> {
return (await this.findAll(_class, query, { ...options, limit: 1 })).shift()
}
async tx (tx: Tx): Promise<TxResult> {
const response = await fetch(concatLink(this.endpoint, `/api/v1/tx/${this.workspace}`), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + this.token
},
keepalive: true,
body: JSON.stringify(tx)
})
if (!response.ok) {
throw new PlatformError(unknownError(response.statusText))
}
return (await response.json()) as TxResult
}
}

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 { createRestClient } from './rest'
export { createRestTxOperations } from './tx'
export * from './types'

View File

@ -0,0 +1,199 @@
//
// 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 {
type Account,
type Class,
type Doc,
type DocumentQuery,
type FindOptions,
type FindResult,
Hierarchy,
MeasureMetricsContext,
ModelDb,
type Ref,
type SearchOptions,
type SearchQuery,
type SearchResult,
type Tx,
type TxResult,
type WithLookup,
buildModel,
concatLink
} from '@hcengineering/core'
import { PlatformError, unknownError } from '@hcengineering/platform'
import type { RestClient } from './types'
import { extractJson, withRetry } from './utils'
export async function createRestClient (endpoint: string, workspaceId: string, token: string): Promise<RestClient> {
return new RestClientImpl(endpoint, workspaceId, token)
}
export class RestClientImpl implements RestClient {
endpoint: string
constructor (
endpoint: string,
readonly workspace: string,
readonly token: string
) {
this.endpoint = endpoint.replace('ws', 'http')
}
jsonHeaders (): Record<string, string> {
return {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + this.token,
'accept-encoding': 'snappy, gzip'
}
}
requestInit (): RequestInit {
return {
method: 'GET',
keepalive: true,
headers: this.jsonHeaders()
}
}
async findAll<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<FindResult<T>> {
const params = new URLSearchParams()
params.append('class', _class)
if (query !== undefined && Object.keys(query).length > 0) {
params.append('query', JSON.stringify(query))
}
if (options !== undefined && Object.keys(options).length > 0) {
params.append('options', JSON.stringify(options))
}
const requestUrl = concatLink(this.endpoint, `/api/v1/find-all/${this.workspace}?${params.toString()}`)
const result = await withRetry(async () => {
const response = await fetch(requestUrl, this.requestInit())
if (!response.ok) {
throw new PlatformError(unknownError(response.statusText))
}
return await extractJson<FindResult<T>>(response)
})
if (result.lookupMap !== undefined) {
// We need to extract lookup map to document lookups
for (const d of result) {
if (d.$lookup !== undefined) {
for (const [k, v] of Object.entries(d.$lookup)) {
if (!Array.isArray(v)) {
d.$lookup[k] = result.lookupMap[v as any]
} else {
d.$lookup[k] = v.map((it) => result.lookupMap?.[it])
}
}
}
}
delete result.lookupMap
}
// We need to revert deleted query simple values.
// We need to get rid of simple query parameters matched in documents
for (const doc of result) {
if (doc._class == null) {
doc._class = _class
}
for (const [k, v] of Object.entries(query)) {
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
if (doc[k] == null) {
doc[k] = v
}
}
}
}
return result
}
async getAccount (): Promise<Account> {
const requestUrl = concatLink(this.endpoint, `/api/v1/account/${this.workspace}`)
const response = await fetch(requestUrl, this.requestInit())
if (!response.ok) {
throw new PlatformError(unknownError(response.statusText))
}
return await extractJson<Account>(response)
}
async getModel (): Promise<{ hierarchy: Hierarchy, model: ModelDb }> {
const requestUrl = concatLink(this.endpoint, `/api/v1/load-model/${this.workspace}`)
const response = await fetch(requestUrl, this.requestInit())
if (!response.ok) {
throw new PlatformError(unknownError(response.statusText))
}
const modelResponse: Tx[] = await extractJson<Tx[]>(response)
const hierarchy = new Hierarchy()
const model = new ModelDb(hierarchy)
const ctx = new MeasureMetricsContext('loadModel', {})
buildModel(ctx, modelResponse, (txes: Tx[]) => txes, hierarchy, model)
return { hierarchy, model }
}
async findOne<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<WithLookup<T> | undefined> {
return (await this.findAll(_class, query, { ...options, limit: 1 })).shift()
}
async tx (tx: Tx): Promise<TxResult> {
const requestUrl = concatLink(this.endpoint, `/api/v1/tx/${this.workspace}`)
const response = await fetch(requestUrl, {
method: 'POST',
headers: this.jsonHeaders(),
keepalive: true,
body: JSON.stringify(tx)
})
if (!response.ok) {
throw new PlatformError(unknownError(response.statusText))
}
return await extractJson<TxResult>(response)
}
async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
const params = new URLSearchParams()
params.append('query', query.query)
if (query.classes != null && Object.keys(query.classes).length > 0) {
params.append('classes', JSON.stringify(query.classes))
}
if (query.spaces != null && Object.keys(query.spaces).length > 0) {
params.append('spaces', JSON.stringify(query.spaces))
}
if (options.limit != null) {
params.append('limit', `${options.limit}`)
}
const requestUrl = concatLink(this.endpoint, `/api/v1/search-fulltext/${this.workspace}`)
const response = await fetch(requestUrl, {
method: 'GET',
headers: this.jsonHeaders(),
keepalive: true
})
if (!response.ok) {
throw new PlatformError(unknownError(response.statusText))
}
return await extractJson<TxResult>(response)
}
}

View File

@ -0,0 +1,101 @@
//
// 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 {
type Account,
type Class,
type Client,
type Doc,
type DocumentQuery,
type FindOptions,
type FindResult,
Hierarchy,
ModelDb,
type Ref,
type SearchOptions,
type SearchQuery,
type SearchResult,
toFindResult,
type Tx,
TxOperations,
type TxResult,
type WithLookup
} from '@hcengineering/core'
import { RestClientImpl } from './rest'
export async function createRestTxOperations (
endpoint: string,
workspaceId: string,
token: string
): Promise<TxOperations> {
const restClient = new RestClientImpl(endpoint, workspaceId, token)
const account = await restClient.getAccount()
const { hierarchy, model } = await restClient.getModel()
return new TxOperations(new RestTxClient(restClient, hierarchy, model, account), account._id)
}
class RestTxClient implements Client {
constructor (
readonly client: RestClientImpl,
readonly hierarchy: Hierarchy,
readonly model: ModelDb,
readonly account: Account
) {}
close (): Promise<void> {
return Promise.resolve()
}
async findAll<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<FindResult<T>> {
const data = await this.client.findAll(_class, query, options)
const result = data.map((v) => {
return this.hierarchy.updateLookupMixin(_class, v, options)
})
return toFindResult(result, data.total)
}
async findOne<T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<WithLookup<T> | undefined> {
const v = await this.client.findOne(_class, query, options)
if (v === undefined) {
return
}
return this.hierarchy.updateLookupMixin(_class, v, options)
}
getHierarchy: () => Hierarchy = () => this.hierarchy
getModel: () => ModelDb = () => this.model
async getAccount (): Promise<Account> {
return this.account
}
async tx (tx: Tx): Promise<TxResult> {
return await this.client.tx(tx)
}
async searchFulltext (query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
return await this.client.searchFulltext(query, options)
}
}

View File

@ -0,0 +1,39 @@
//
// 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 {
type Account,
type Class,
type Doc,
type DocumentQuery,
type FindOptions,
type Hierarchy,
type ModelDb,
type Ref,
type Storage,
type WithLookup
} from '@hcengineering/core'
export interface RestClient extends Storage {
getAccount: () => Promise<Account>
findOne: <T extends Doc>(
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
) => Promise<WithLookup<T> | undefined>
getModel: () => Promise<{ hierarchy: Hierarchy, model: ModelDb }>
}

View File

@ -0,0 +1,42 @@
import { uncompress } from 'snappyjs'
export async function withRetry<T> (fn: () => Promise<T>): Promise<T> {
const maxRetries = 3
let lastError: any
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn()
} catch (err: any) {
lastError = err
if (attempt === maxRetries - 1) {
console.error('Failed to execute query', err)
throw lastError
}
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 100))
}
}
throw lastError
}
function rpcJSONReceiver (key: string, value: any): any {
if (typeof value === 'object' && value !== null) {
if (value.dataType === 'TotalArray') {
return Object.assign(value.value, { total: value.total, lookupMap: value.lookupMap })
}
}
return value
}
export async function extractJson<T> (response: Response): Promise<any> {
const encoding = response.headers.get('content-encoding')
if (encoding === 'snappy') {
const buffer = await response.arrayBuffer()
const decompressed = uncompress(buffer)
const decoder = new TextDecoder()
const jsonString = decoder.decode(decompressed)
return JSON.parse(jsonString, rpcJSONReceiver) as T
}
const jsonString = await response.text()
return JSON.parse(jsonString, rpcJSONReceiver) as T
}

View File

@ -404,7 +404,7 @@ async function loadModel (
return { mode: 'addition', current: current.transactions, addition: result.transactions } return { mode: 'addition', current: current.transactions, addition: result.transactions }
} }
function buildModel ( export function buildModel (
ctx: MeasureContext, ctx: MeasureContext,
transactions: Tx[], transactions: Tx[],
modelFilter: ModelFilter | undefined, modelFilter: ModelFilter | undefined,

View File

@ -455,6 +455,11 @@
"projectFolder": "packages/api-client", "projectFolder": "packages/api-client",
"shouldPublish": false "shouldPublish": false
}, },
{
"packageName": "@hcengineering/api-tests",
"projectFolder": "ws-tests/api-tests",
"shouldPublish": false
},
{ {
"packageName": "@hcengineering/importer", "packageName": "@hcengineering/importer",
"projectFolder": "packages/importer", "projectFolder": "packages/importer",

View File

@ -547,6 +547,8 @@ export interface Session {
getUser: () => string getUser: () => string
loadModel: (ctx: ClientSessionCtx, lastModelTx: Timestamp, hash?: string) => Promise<void> loadModel: (ctx: ClientSessionCtx, lastModelTx: Timestamp, hash?: string) => Promise<void>
loadModelRaw: (ctx: ClientSessionCtx, lastModelTx: Timestamp, hash?: string) => Promise<LoadModelResponse | Tx[]>
getAccount: (ctx: ClientSessionCtx) => Promise<void> getAccount: (ctx: ClientSessionCtx) => Promise<void>
getRawAccount: (pipeline: Pipeline) => Account getRawAccount: (pipeline: Pipeline) => Account
@ -564,6 +566,7 @@ export interface Session {
options?: FindOptions<T> options?: FindOptions<T>
) => Promise<FindResult<T>> ) => Promise<FindResult<T>>
searchFulltext: (ctx: ClientSessionCtx, query: SearchQuery, options: SearchOptions) => Promise<void> searchFulltext: (ctx: ClientSessionCtx, query: SearchQuery, options: SearchOptions) => Promise<void>
searchFulltextRaw: (ctx: ClientSessionCtx, query: SearchQuery, options: SearchOptions) => Promise<SearchResult>
tx: (ctx: ClientSessionCtx, tx: Tx) => Promise<void> tx: (ctx: ClientSessionCtx, tx: Tx) => Promise<void>
txRaw: ( txRaw: (

View File

@ -14,12 +14,19 @@
// //
import core, { import core, {
type Class,
type Doc, type Doc,
type DocumentQuery,
type FindOptions,
type FindResult,
type LoadModelResponse, type LoadModelResponse,
type MeasureContext, type MeasureContext,
type Ref,
type SessionData,
type Timestamp, type Timestamp,
type Tx, type Tx,
type TxCUD, type TxCUD,
DOMAIN_MODEL,
DOMAIN_TX, DOMAIN_TX,
withContext withContext
} from '@hcengineering/core' } from '@hcengineering/core'
@ -78,6 +85,19 @@ export class ModelMiddleware extends BaseMiddleware implements Middleware {
return allUserTxes.filter((it) => isUserTx(it)) return allUserTxes.filter((it) => isUserTx(it))
} }
findAll<T extends Doc>(
ctx: MeasureContext<SessionData>,
_class: Ref<Class<T>>,
query: DocumentQuery<T>,
options?: FindOptions<T>
): Promise<FindResult<T>> {
const d = this.context.hierarchy.findDomain(_class)
if (d === DOMAIN_MODEL) {
return this.context.modelDb.findAll(_class, query, options)
}
return this.provideFindAll(ctx, _class, query, options)
}
async init (ctx: MeasureContext): Promise<void> { async init (ctx: MeasureContext): Promise<void> {
if (this.context.adapterManager == null) { if (this.context.adapterManager == null) {
throw new PlatformError(unknownError('Adapter manager should be configured')) throw new PlatformError(unknownError('Adapter manager should be configured'))

View File

@ -30,6 +30,8 @@ export function createMongoDestroyAdapter (url: string): WorkspaceDestroyAdapter
const db = getWorkspaceMongoDB(dbClient, workspace) const db = getWorkspaceMongoDB(dbClient, workspace)
await db.dropDatabase() await db.dropDatabase()
}) })
} catch (err) {
console.error('Failed to delete workspace', err)
} finally { } finally {
client.close() client.close()
} }

View File

@ -53,7 +53,7 @@ export interface HelloResponse extends Response<any> {
useCompression?: boolean useCompression?: boolean
} }
function replacer (key: string, value: any): any { export function rpcJSONReplacer (key: string, value: any): any {
if (Array.isArray(value) && ((value as any).total !== undefined || (value as any).lookupMap !== undefined)) { if (Array.isArray(value) && ((value as any).total !== undefined || (value as any).lookupMap !== undefined)) {
return { return {
dataType: 'TotalArray', dataType: 'TotalArray',
@ -66,7 +66,7 @@ function replacer (key: string, value: any): any {
} }
} }
function receiver (key: string, value: any): any { export function rpcJSONReceiver (key: string, value: any): any {
if (typeof value === 'object' && value !== null) { if (typeof value === 'object' && value !== null) {
if (value.dataType === 'TotalArray') { if (value.dataType === 'TotalArray') {
return Object.assign(value.value, { total: value.total, lookupMap: value.lookupMap }) return Object.assign(value.value, { total: value.total, lookupMap: value.lookupMap })
@ -99,7 +99,7 @@ export class RPCHandler {
packr = new Packr({ structuredClone: true, bundleStrings: true, copyBuffers: false }) packr = new Packr({ structuredClone: true, bundleStrings: true, copyBuffers: false })
protoSerialize (object: object, binary: boolean): any { protoSerialize (object: object, binary: boolean): any {
if (!binary) { if (!binary) {
return JSON.stringify(object, replacer) return JSON.stringify(object, rpcJSONReplacer)
} }
return new Uint8Array(this.packr.pack(object)) return new Uint8Array(this.packr.pack(object))
} }
@ -112,7 +112,7 @@ export class RPCHandler {
_data = decoder.decode(_data) _data = decoder.decode(_data)
} }
try { try {
return JSON.parse(_data.toString(), receiver) return JSON.parse(_data.toString(), rpcJSONReceiver)
} catch (err: any) { } catch (err: any) {
if (((err.message as string) ?? '').includes('Unexpected token')) { if (((err.message as string) ?? '').includes('Unexpected token')) {
return this.packr.unpack(new Uint8Array(data)) return this.packr.unpack(new Uint8Array(data))
@ -129,7 +129,7 @@ export class RPCHandler {
*/ */
serialize (object: Request<any> | Response<any>, binary: boolean): any { serialize (object: Request<any> | Response<any>, binary: boolean): any {
if ((object as any).result !== undefined) { if ((object as any).result !== undefined) {
;(object as any).result = replacer('result', (object as any).result) ;(object as any).result = rpcJSONReplacer('result', (object as any).result)
} }
return this.protoSerialize(object, binary) return this.protoSerialize(object, binary)
} }
@ -142,7 +142,7 @@ export class RPCHandler {
readResponse<D>(response: any, binary: boolean): Response<D> { readResponse<D>(response: any, binary: boolean): Response<D> {
const data = this.protoDeserialize(response, binary) const data = this.protoDeserialize(response, binary)
if (data.result !== undefined) { if (data.result !== undefined) {
data.result = receiver('result', data.result) data.result = rpcJSONReceiver('result', data.result)
} }
return data return data
} }

View File

@ -23,10 +23,12 @@ import {
type Domain, type Domain,
type FindOptions, type FindOptions,
type FindResult, type FindResult,
type LoadModelResponse,
type MeasureContext, type MeasureContext,
type Ref, type Ref,
type SearchOptions, type SearchOptions,
type SearchQuery, type SearchQuery,
type SearchResult,
type SessionData, type SessionData,
type Timestamp, type Timestamp,
type Tx, type Tx,
@ -106,6 +108,11 @@ export class ClientSession implements Session {
await ctx.sendResponse(ctx.requestId, result) await ctx.sendResponse(ctx.requestId, result)
} }
async loadModelRaw (ctx: ClientSessionCtx, lastModelTx: Timestamp, hash?: string): Promise<LoadModelResponse | Tx[]> {
this.includeSessionContext(ctx.ctx, ctx.pipeline)
return await ctx.ctx.with('load-model', {}, (_ctx) => ctx.pipeline.loadModel(_ctx, lastModelTx, hash))
}
async getAccount (ctx: ClientSessionCtx): Promise<void> { async getAccount (ctx: ClientSessionCtx): Promise<void> {
await ctx.sendResponse(ctx.requestId, this.getRawAccount(ctx.pipeline)) await ctx.sendResponse(ctx.requestId, this.getRawAccount(ctx.pipeline))
} }
@ -163,6 +170,12 @@ export class ClientSession implements Session {
await ctx.sendResponse(ctx.requestId, await ctx.pipeline.searchFulltext(ctx.ctx, query, options)) await ctx.sendResponse(ctx.requestId, await ctx.pipeline.searchFulltext(ctx.ctx, query, options))
} }
async searchFulltextRaw (ctx: ClientSessionCtx, query: SearchQuery, options: SearchOptions): Promise<SearchResult> {
this.lastRequest = Date.now()
this.includeSessionContext(ctx.ctx, ctx.pipeline)
return await ctx.pipeline.searchFulltext(ctx.ctx, query, options)
}
async txRaw ( async txRaw (
ctx: ClientSessionCtx, ctx: ClientSessionCtx,
tx: Tx tx: Tx

View File

@ -36,7 +36,8 @@
"prettier": "^3.1.0", "prettier": "^3.1.0",
"ts-jest": "^29.1.1", "ts-jest": "^29.1.1",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"@types/body-parser": "~1.19.2" "@types/body-parser": "~1.19.2",
"@types/morgan": "~1.9.9"
}, },
"dependencies": { "dependencies": {
"@hcengineering/analytics": "^0.6.0", "@hcengineering/analytics": "^0.6.0",
@ -53,6 +54,7 @@
"ws": "^8.18.0", "ws": "^8.18.0",
"body-parser": "^1.20.2", "body-parser": "^1.20.2",
"snappy": "^7.2.2", "snappy": "^7.2.2",
"@hcengineering/api-client": "^0.6.0" "@hcengineering/api-client": "^0.6.0",
"morgan": "^1.10.0"
} }
} }

View File

@ -15,7 +15,7 @@
import { generateToken } from '@hcengineering/server-token' import { generateToken } from '@hcengineering/server-token'
import { createRestClient, type RestClient } from '@hcengineering/api-client' import { createRestClient, createRestTxOperations, type RestClient } from '@hcengineering/api-client'
import core, { import core, {
generateId, generateId,
getWorkspaceId, getWorkspaceId,
@ -34,12 +34,13 @@ import core, {
type Space, type Space,
type Tx, type Tx,
type TxCreateDoc, type TxCreateDoc,
type TxOperations,
type TxResult type TxResult
} from '@hcengineering/core' } from '@hcengineering/core'
import { ClientSession, startSessionManager, type TSessionManager } from '@hcengineering/server' import { ClientSession, startSessionManager, type TSessionManager } from '@hcengineering/server'
import { createDummyStorageAdapter, type SessionManager, type WorkspaceLoginInfo } from '@hcengineering/server-core' import { createDummyStorageAdapter, type SessionManager, type WorkspaceLoginInfo } from '@hcengineering/server-core'
import { startHttpServer } from '../server_http' import { startHttpServer } from '../server_http'
import { genMinModel } from './minmodel' import { genMinModel, test } from './minmodel'
describe('rest-server', () => { describe('rest-server', () => {
async function getModelDb (): Promise<{ modelDb: ModelDb, hierarchy: Hierarchy, txes: Tx[] }> { async function getModelDb (): Promise<{ modelDb: ModelDb, hierarchy: Hierarchy, txes: Tx[] }> {
@ -57,7 +58,7 @@ describe('rest-server', () => {
let shutdown: () => Promise<void> let shutdown: () => Promise<void>
let sessionManager: SessionManager let sessionManager: SessionManager
const port: number = 3330 const port: number = 11000
beforeAll(async () => { beforeAll(async () => {
;({ shutdown, sessionManager } = startSessionManager(new MeasureMetricsContext('test', {}), { ;({ shutdown, sessionManager } = startSessionManager(new MeasureMetricsContext('test', {}), {
@ -153,6 +154,11 @@ describe('rest-server', () => {
return await createRestClient(`http://localhost:${port}`, 'test-ws', token) return await createRestClient(`http://localhost:${port}`, 'test-ws', token)
} }
async function connectTx (): Promise<TxOperations> {
const token: string = generateToken('user1@site.com', getWorkspaceId('test-ws'))
return await createRestTxOperations(`http://localhost:${port}`, 'test-ws', token)
}
it('get account', async () => { it('get account', async () => {
const conn = await connect() const conn = await connect()
const account = await conn.getAccount() const account = await conn.getAccount()
@ -222,4 +228,13 @@ describe('rest-server', () => {
const spaces = await conn.findAll(core.class.Space, {}) const spaces = await conn.findAll(core.class.Space, {})
expect(spaces.length).toBe(3) expect(spaces.length).toBe(3)
}) })
it('check-model-operations', async () => {
const conn = await connectTx()
const h = conn.getHierarchy()
const domains = h.domains()
expect(domains.length).toBe(2)
expect(h.isDerived(test.class.TestComment, core.class.AttachedDoc)).toBe(true)
})
}) })

View File

@ -45,6 +45,7 @@ import { startHttpServer } from '../server_http'
import { genMinModel } from './minmodel' import { genMinModel } from './minmodel'
describe('server', () => { describe('server', () => {
const port = 10000
const handler = new RPCHandler() const handler = new RPCHandler()
async function getModelDb (): Promise<{ modelDb: ModelDb, hierarchy: Hierarchy }> { async function getModelDb (): Promise<{ modelDb: ModelDb, hierarchy: Hierarchy }> {
const txes = genMinModel() const txes = genMinModel()
@ -95,7 +96,7 @@ describe('server', () => {
} }
}, },
sessionFactory: (token, workspace) => new ClientSession(token, workspace, true), sessionFactory: (token, workspace) => new ClientSession(token, workspace, true),
port: 3335, port,
brandingMap: {}, brandingMap: {},
serverFactory: startHttpServer, serverFactory: startHttpServer,
accountsUrl: '', accountsUrl: '',
@ -104,7 +105,7 @@ describe('server', () => {
function connect (): WebSocket { function connect (): WebSocket {
const token: string = generateToken('', getWorkspaceId('latest')) const token: string = generateToken('', getWorkspaceId('latest'))
return new WebSocket(`ws://localhost:3335/${token}`) return new WebSocket(`ws://localhost:${port}/${token}`)
} }
afterAll(async () => { afterAll(async () => {
@ -122,7 +123,7 @@ describe('server', () => {
}) })
it('should not connect to server without token', (done) => { it('should not connect to server without token', (done) => {
const conn = new WebSocket('ws://localhost:3335/xyz') const conn = new WebSocket(`ws://localhost:${port}/xyz`)
conn.on('error', () => { conn.on('error', () => {
conn.close(1000) conn.close(1000)
}) })
@ -206,7 +207,7 @@ describe('server', () => {
} }
}, },
sessionFactory: (token, workspace) => new ClientSession(token, workspace, true), sessionFactory: (token, workspace) => new ClientSession(token, workspace, true),
port: 3336, port: port + 1,
brandingMap: {}, brandingMap: {},
serverFactory: startHttpServer, serverFactory: startHttpServer,
accountsUrl: '', accountsUrl: '',
@ -214,7 +215,7 @@ describe('server', () => {
}) })
async function findClose (token: string, timeoutPromise: Promise<void>, code: number): Promise<string> { async function findClose (token: string, timeoutPromise: Promise<void>, code: number): Promise<string> {
const newConn = new WebSocket(`ws://localhost:3336/${token}?sessionId=s1`) const newConn = new WebSocket(`ws://localhost:${port + 1}/${token}?sessionId=s1`)
await Promise.race([ await Promise.race([
timeoutPromise, timeoutPromise,

View File

@ -1,4 +1,13 @@
import type { Class, Doc, MeasureContext, Ref } from '@hcengineering/core' import core, {
TxProcessor,
type Class,
type Doc,
type MeasureContext,
type Ref,
type SearchOptions,
type SearchQuery,
type TxCUD
} from '@hcengineering/core'
import type { import type {
ClientSessionCtx, ClientSessionCtx,
ConnectionSocket, ConnectionSocket,
@ -8,6 +17,8 @@ import type {
} from '@hcengineering/server-core' } from '@hcengineering/server-core'
import { decodeToken } from '@hcengineering/server-token' import { decodeToken } from '@hcengineering/server-token'
import { rpcJSONReplacer } from '@hcengineering/rpc'
import { createHash } from 'crypto'
import { type Express, type Response as ExpressResponse, type Request } from 'express' import { type Express, type Response as ExpressResponse, type Request } from 'express'
import type { OutgoingHttpHeaders } from 'http2' import type { OutgoingHttpHeaders } from 'http2'
import { compress } from 'snappy' import { compress } from 'snappy'
@ -32,14 +43,31 @@ const sendError = (res: ExpressResponse, code: number, data: any): void => {
res.end(JSON.stringify(data)) res.end(JSON.stringify(data))
} }
async function sendJson (req: Request, res: ExpressResponse, result: any): Promise<void> { async function sendJson (
req: Request,
res: ExpressResponse,
result: any,
extraHeaders?: OutgoingHttpHeaders
): Promise<void> {
// Calculate ETag
let body: any = JSON.stringify(result, rpcJSONReplacer)
const etag = createHash('sha1').update(body).digest('hex')
const headers: OutgoingHttpHeaders = { const headers: OutgoingHttpHeaders = {
...(extraHeaders ?? {}),
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
Connection: 'keep-alive', connection: 'keep-alive',
'keep-alive': 'timeout=5, max=1000' 'keep-alive': 'timeout=5, max=1000',
ETag: etag
}
// Check if the ETag matches
if (req.headers['if-none-match'] === etag) {
res.writeHead(304, headers)
res.end()
return
} }
let body: any = JSON.stringify(result)
const contentEncodings: string[] = const contentEncodings: string[] =
typeof req.headers['accept-encoding'] === 'string' typeof req.headers['accept-encoding'] === 'string'
@ -63,7 +91,7 @@ async function sendJson (req: Request, res: ExpressResponse, result: any): Promi
break break
} }
} }
headers['content-length'] = body.length
res.writeHead(200, headers) res.writeHead(200, headers)
res.end(body) res.end(body)
} }
@ -81,41 +109,42 @@ export function registerRPC (
res: ExpressResponse, res: ExpressResponse,
operation: (ctx: ClientSessionCtx, session: Session) => Promise<void> operation: (ctx: ClientSessionCtx, session: Session) => Promise<void>
): Promise<void> { ): Promise<void> {
if (req.params.workspaceId === undefined || req.params.workspaceId === '') { try {
res.writeHead(400, {}) if (req.params.workspaceId === undefined || req.params.workspaceId === '') {
res.end('Missing workspace') res.writeHead(400, {})
return res.end('Missing workspace')
}
let token = req.headers.authorization as string
if (token === null) {
sendError(res, 401, { message: 'Missing Authorization header' })
return
}
const workspaceId = decodeURIComponent(req.params.workspaceId)
token = token.split(' ')[1]
const decodedToken = decodeToken(token)
if (workspaceId !== decodedToken.workspace.name) {
sendError(res, 401, { message: 'Invalid workspace' })
return
}
let transactorRpc = rpcSessions.get(token)
if (transactorRpc === undefined) {
const cs: ConnectionSocket = createClosingSocket(token, rpcSessions)
const s = await sessions.addSession(ctx, cs, decodedToken, token, pipelineFactory, token)
if (!('session' in s)) {
sendError(res, 401, {
message: 'Failed to create session',
mode: 'specialError' in s ? s.specialError ?? '' : 'upgrading'
})
return return
} }
transactorRpc = { session: s.session, client: cs, workspaceId: s.workspaceId } let token = req.headers.authorization as string
rpcSessions.set(token, transactorRpc) if (token === null) {
} sendError(res, 401, { message: 'Missing Authorization header' })
try { return
}
const workspaceId = decodeURIComponent(req.params.workspaceId)
token = token.split(' ')[1]
const decodedToken = decodeToken(token)
if (workspaceId !== decodedToken.workspace.name) {
sendError(res, 401, { message: 'Invalid workspace', workspace: decodedToken.workspace.name })
return
}
let transactorRpc = rpcSessions.get(token)
if (transactorRpc === undefined) {
const cs: ConnectionSocket = createClosingSocket(token, rpcSessions)
const s = await sessions.addSession(ctx, cs, decodedToken, token, pipelineFactory, token)
if (!('session' in s)) {
sendError(res, 401, {
message: 'Failed to create session',
mode: 'specialError' in s ? s.specialError ?? '' : 'upgrading'
})
return
}
transactorRpc = { session: s.session, client: cs, workspaceId: s.workspaceId }
rpcSessions.set(token, transactorRpc)
}
const rpc = transactorRpc const rpc = transactorRpc
await sessions.handleRPC(ctx, rpc.session, rpc.client, async (ctx) => { await sessions.handleRPC(ctx, rpc.session, rpc.client, async (ctx) => {
await operation(ctx, rpc.session) await operation(ctx, rpc.session)
@ -128,7 +157,11 @@ export function registerRPC (
app.get('/api/v1/ping/:workspaceId', (req, res) => { app.get('/api/v1/ping/:workspaceId', (req, res) => {
void withSession(req, res, async (ctx, session) => { void withSession(req, res, async (ctx, session) => {
await session.ping(ctx) await session.ping(ctx)
await sendJson(req, res, { pong: true }) await sendJson(req, res, {
pong: true,
lastTx: ctx.pipeline.context.lastTx,
lastHash: ctx.pipeline.context.lastHash
})
}) })
}) })
@ -166,6 +199,50 @@ export function registerRPC (
await sendJson(req, res, result) await sendJson(req, res, result)
}) })
}) })
app.get('/api/v1/load-model/:workspaceId', (req, res) => {
void withSession(req, res, async (ctx, session) => {
const lastModelTx = parseInt((req.query.lastModelTx as string) ?? '0')
const lastHash = req.query.lastHash as string
const result = await session.loadModelRaw(ctx, lastModelTx, lastHash)
const txes = Array.isArray(result) ? result : result.transactions
// we need to filter only hierarchy related txes.
const allowedClasess: Ref<Class<Doc>>[] = [
core.class.Class,
core.class.Attribute,
core.class.Mixin,
core.class.Type,
core.class.Status,
core.class.Account,
core.class.Permission,
core.class.Space,
core.class.Tx
]
const h = ctx.pipeline.context.hierarchy
const filtered = txes.filter(
(it) =>
TxProcessor.isExtendsCUD(it._class) &&
allowedClasess.some((cl) => h.isDerived((it as TxCUD<Doc>).objectClass, cl))
)
await sendJson(req, res, filtered)
})
})
app.get('/api/v1/search-fulltext/:workspaceId', (req, res) => {
void withSession(req, res, async (ctx, session) => {
const query: SearchQuery = {
query: req.query.query as string,
classes: req.query.classes !== undefined ? JSON.parse(req.query.classes as string) : undefined,
spaces: req.query.spaces !== undefined ? JSON.parse(req.query.spaces as string) : undefined
}
const options: SearchOptions = {
limit: req.query.limit !== undefined ? parseInt(req.query.limit as string) : undefined
}
const result = await session.searchFulltextRaw(ctx, query, options)
await sendJson(req, res, result)
})
})
} }
function createClosingSocket (rawToken: string, rpcSessions: Map<string, RPCClientInfo>): ConnectionSocket { function createClosingSocket (rawToken: string, rpcSessions: Map<string, RPCClientInfo>): ConnectionSocket {

View File

@ -49,6 +49,7 @@ import { compress } from 'snappy'
import 'utf-8-validate' import 'utf-8-validate'
import { registerRPC } from './rpc' import { registerRPC } from './rpc'
import { retrieveJson } from './utils' import { retrieveJson } from './utils'
import morgan from 'morgan'
import { setImmediate } from 'timers/promises' import { setImmediate } from 'timers/promises'
@ -82,6 +83,21 @@ export function startHttpServer (
const app = express() const app = express()
app.use(cors()) app.use(cors())
const childLogger = ctx.logger.childLogger?.('requests', {
enableConsole: 'true'
})
const requests = ctx.newChild('requests', {}, {}, childLogger)
class MyStream {
write (text: string): void {
requests.info(text)
}
}
const myStream = new MyStream()
app.use(morgan('short', { stream: myStream }))
const getUsers = (): any => Array.from(sessions.sessions.entries()).map(([k, v]) => v.session.getUser()) const getUsers = (): any => Array.from(sessions.sessions.entries()).map(([k, v]) => v.session.getUser())
app.get('/api/v1/version', (req, res) => { app.get('/api/v1/version', (req, res) => {

View File

@ -0,0 +1,7 @@
module.exports = {
extends: ['./node_modules/@hcengineering/platform-rig/profiles/node/eslint.config.json'],
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json'
}
}

2
ws-tests/api-tests/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
v*.zip
src/uws

View File

@ -0,0 +1,4 @@
*
!/lib/**
!CHANGELOG.md
/lib/**/__tests__/

View File

@ -0,0 +1,5 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json",
"rigPackageName": "@hcengineering/platform-rig",
"rigProfile": "node"
}

View File

@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'],
roots: ["./src"],
coverageReporters: ["text-summary", "html"]
}

View File

@ -0,0 +1,59 @@
{
"name": "@hcengineering/api-tests",
"version": "0.6.0",
"main": "lib/index.js",
"svelte": "src/index.ts",
"types": "types/index.d.ts",
"author": "Anticrm Platform Contributors",
"template": "@hcengineering/node-package-ws",
"license": "EPL-2.0",
"scripts": {
"build": "compile",
"build:watch": "compile",
"test": "echo 'run api-test' for API-tests",
"api-test": "jest --passWithNoTests --silent --forceExit",
"format": "format src",
"_phase:build": "compile transpile src",
"_phase:test": "echo 'run api-test' for API-tests",
"_phase:format": "format src",
"_phase:validate": "compile validate"
},
"devDependencies": {
"@hcengineering/platform-rig": "^0.6.0",
"@types/compression": "~1.7.2",
"@types/cors": "^2.8.12",
"@types/express": "^4.17.13",
"@types/jest": "^29.5.5",
"@types/node": "~20.11.16",
"@types/ws": "^8.5.11",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"eslint": "^8.54.0",
"eslint-config-standard-with-typescript": "^40.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-n": "^15.4.0",
"eslint-plugin-promise": "^6.1.1",
"jest": "^29.7.0",
"prettier": "^3.1.0",
"ts-jest": "^29.1.1",
"typescript": "^5.3.3",
"@types/body-parser": "~1.19.2",
"@types/morgan": "~1.9.9"
},
"dependencies": {
"@hcengineering/analytics": "^0.6.0",
"@hcengineering/core": "^0.6.32",
"@hcengineering/platform": "^0.6.11",
"@hcengineering/rpc": "^0.6.5",
"@hcengineering/server-token": "^0.6.11",
"bufferutil": "^4.0.8",
"utf-8-validate": "^6.0.4",
"ws": "^8.18.0",
"snappyjs": "^0.7.0",
"@hcengineering/api-client": "^0.6.0",
"@hcengineering/tracker": "^0.6.24",
"@hcengineering/task": "^0.6.20",
"@hcengineering/contact": "^0.6.24",
"@hcengineering/chunter": "^0.6.20"
}
}

View File

@ -0,0 +1,178 @@
//
// 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 {
createRestClient,
createRestTxOperations,
getWorkspaceToken,
type RestClient,
type WorkspaceToken
} from '@hcengineering/api-client'
import core, { generateId, type Space, type TxCreateDoc, type TxOperations } from '@hcengineering/core'
import chunter from '@hcengineering/chunter'
import contact from '@hcengineering/contact'
describe('rest-api-server', () => {
const wsName = 'api-tests'
let apiWorkspace1: WorkspaceToken
let apiWorkspace2: WorkspaceToken
beforeAll(async () => {
apiWorkspace1 = await getWorkspaceToken('http://localhost:8083', {
email: 'user1',
password: '1234',
workspace: wsName
})
apiWorkspace2 = await getWorkspaceToken('http://localhost:8083', {
email: 'user1',
password: '1234',
workspace: wsName + '-cr'
})
})
async function connect (ws?: WorkspaceToken): Promise<RestClient> {
const tok = ws ?? apiWorkspace1
return await createRestClient(tok.endpoint, tok.workspaceId, tok.token)
}
async function connectTx (ws?: WorkspaceToken): Promise<TxOperations> {
const tok = ws ?? apiWorkspace1
return await createRestTxOperations(tok.endpoint, tok.workspaceId, tok.token)
}
it('get account', async () => {
const conn = await connect()
const account = await conn.getAccount()
expect(account.email).toBe('user1')
expect(account.role).toBe('USER')
expect(account._class).toBe(contact.class.PersonAccount)
expect(account.space).toBe(core.space.Model)
expect(account.modifiedBy).toBe(core.account.System)
expect(account.createdBy).toBe(core.account.System)
expect(typeof account.modifiedOn).toBe('number')
expect(typeof account.createdOn).toBe('number')
})
it('find spaces', async () => {
const conn = await connect()
const spaces = await conn.findAll(core.class.Space, {})
expect(spaces.length).toBeGreaterThanOrEqual(20)
const personSpace = spaces.find((it) => it.name === 'Pesonal space' && it.private)
expect(personSpace).not.toBeNull()
})
it('find spaces limit', async () => {
const conn = await connect()
const spaces = await conn.findAll(core.class.Space, {}, { limit: 5 })
expect(spaces.length).toBe(5)
})
it('find spaces by-name', async () => {
const conn = await connect()
const spaces = await conn.findAll(
contact.class.PersonSpace,
{ name: 'Personal space' },
{
lookup: {
person: contact.class.Person
}
}
)
expect(spaces.length).toBe(1)
expect(spaces[0].name).toBe('Personal space')
expect(spaces[0].$lookup?.person?.name).toBe('Appleseed,John')
})
it('find channels', async () => {
const conn = await connect()
const spaces = await conn.findAll(chunter.class.Channel, {})
expect(spaces.length).toBeGreaterThanOrEqual(2)
expect(spaces.find((it) => it._id === 'chunter:space:General')).not.toBeNull()
})
it('find avg', async () => {
const conn = await connect()
await checkFindPerformance(conn) // 5ms max per operation
})
it('find avg-europe', async () => {
const conn = await connect(apiWorkspace2)
await checkFindPerformance(conn) // 5ms max per operation
})
it('add space', async () => {
const conn = await connect()
const account = await conn.getAccount()
const spaceName = generateId()
const tx: TxCreateDoc<Space> = {
_class: core.class.TxCreateDoc,
space: core.space.Tx,
_id: generateId(),
objectSpace: core.space.Model,
modifiedBy: account._id,
modifiedOn: Date.now(),
attributes: {
name: spaceName,
description: '',
private: false,
archived: false,
members: [],
autoJoin: false
},
objectClass: core.class.Space,
objectId: generateId()
}
await conn.tx(tx)
const spaces = await conn.findAll(core.class.Space, {})
expect(spaces.filter((it) => it.name === spaceName).length).toBe(1)
})
it('get-model', async () => {
const conn = await connect()
const { hierarchy, model } = await conn.getModel()
const dsc = hierarchy.getDescendants(core.class.Space)
expect(dsc.length).toBe(32)
expect(model.getObject(core.class.Space)).not.toBeNull()
})
it('tx-client', async () => {
const conn = await connectTx()
const employee = await conn.findAll(contact.mixin.Employee, {}, { limit: 5 })
expect(employee.length).toBeGreaterThanOrEqual(1)
expect(employee[0].active).toBe(true)
})
})
async function checkFindPerformance (conn: RestClient): Promise<void> {
let ops = 0
let total = 0
const attempts = 1000
for (let i = 0; i < attempts; i++) {
const st = performance.now()
const spaces = await conn.findAll(core.class.Space, {})
expect(spaces.length).toBeGreaterThanOrEqual(22)
const ed = performance.now()
ops++
total += ed - st
}
const avg = total / ops
// console.log('ops:', ops, 'total:', total, 'avg:', )
expect(ops).toEqual(attempts)
expect(avg).toBeLessThan(5)
}

View File

View File

@ -0,0 +1,10 @@
{
"extends": "./node_modules/@hcengineering/platform-rig/profiles/node/tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./lib",
"declarationDir": "./types",
"tsBuildInfoFile": ".build/build.tsbuildinfo"
}
}

View File

@ -27,3 +27,9 @@ fi
./tool.sh confirm-email user1 ./tool.sh confirm-email user1
./tool.sh confirm-email user2 ./tool.sh confirm-email user2
./tool.sh create-workspace api-tests -w api-tests
./tool-europe.sh create-workspace api-tests-cr -w api-tests --region 'europe'
./tool.sh assign-workspace user1 api-tests
./tool.sh assign-workspace user1 api-tests-cr

19
ws-tests/tool-europe.sh Executable file
View File

@ -0,0 +1,19 @@
#!/usr/bin/env bash
export MODEL_VERSION=$(node ../common/scripts/show_version.js)
export MINIO_ACCESS_KEY=minioadmin
export MINIO_SECRET_KEY=minioadmin
export MINIO_ENDPOINT=localhost:9002
export ACCOUNTS_URL=http://localhost:3003
export TRANSACTOR_URL=ws://localhost:3334
# export ACCOUNT_DB_URL=postgresql://root@localhost:26258/defaultdb?sslmode=disable
export ACCOUNT_DB_URL=mongodb://localhost:27018
export MONGO_URL=mongodb://localhost:27018
export ELASTIC_URL=http://localhost:9201
export SERVER_SECRET=secret
export DB_URL=postgresql://root@localhost:26258/defaultdb?sslmode=disable
export REGION_INFO="|America;europe|" # Europe without name will not be available for creation of new workspaces.
export TRANSACTOR_URL="ws://transactor:3334;ws://localhost:3334,ws://transactor-europe:3335;ws://localhost:3335;europe,"
node ${TOOL_OPTIONS} --max-old-space-size=8096 ../dev/tool/bundle/bundle.js $@

View File

@ -11,6 +11,6 @@ export ACCOUNT_DB_URL=mongodb://localhost:27018
export MONGO_URL=mongodb://localhost:27018 export MONGO_URL=mongodb://localhost:27018
export ELASTIC_URL=http://localhost:9201 export ELASTIC_URL=http://localhost:9201
export SERVER_SECRET=secret export SERVER_SECRET=secret
export DB_URL=postgresql://root@localhost:26258/defaultdb?sslmode=disable export DB_URL=$MONGO_URL
node ${TOOL_OPTIONS} --max-old-space-size=8096 ../dev/tool/bundle/bundle.js $@ node ${TOOL_OPTIONS} --max-old-space-size=8096 ../dev/tool/bundle/bundle.js $@