@highstate/backend-api 0.27.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/highstate.manifest.json +1 -1
- package/dist/index.js +1202 -93
- package/package.json +12 -10
- package/src/handlers/instance-state.ts +96 -0
- package/src/handlers/library.ts +63 -0
- package/src/handlers/operation.ts +108 -0
- package/src/handlers/panel.ts +7 -6
- package/src/handlers/project-model.ts +131 -0
- package/src/handlers/project.ts +46 -0
- package/src/handlers/secret.ts +6 -5
- package/src/handlers/worker.ts +29 -32
- package/src/index.ts +125 -19
- package/src/shared/api-error.ts +42 -0
- package/src/shared/authentication.ts +172 -12
- package/src/shared/authorization-header.test.ts +36 -0
- package/src/shared/authorization-header.ts +24 -0
- package/src/shared/conversion.test.ts +122 -0
- package/src/shared/conversion.ts +593 -0
- package/src/shared/error-handling.test.ts +138 -0
- package/src/shared/error-handling.ts +127 -14
- package/src/shared/field-mask.test.ts +37 -0
- package/src/shared/field-mask.ts +81 -0
- package/src/shared/index.ts +5 -0
- package/src/shared/serialization.test.ts +17 -0
- package/src/shared/serialization.ts +35 -0
- package/src/shared/validation.ts +45 -5
- package/src/handlers/instance.ts +0 -44
package/src/index.ts
CHANGED
|
@@ -1,37 +1,143 @@
|
|
|
1
1
|
import type { Services } from "@highstate/backend"
|
|
2
2
|
import { rm } from "node:fs/promises"
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
import { createServer, type RequestListener, type Server } from "node:http"
|
|
4
|
+
import { connectNodeAdapter } from "@connectrpc/connect-node"
|
|
5
|
+
import {
|
|
6
|
+
InstanceStateService,
|
|
7
|
+
LibraryService,
|
|
8
|
+
OperationService,
|
|
9
|
+
PanelService,
|
|
10
|
+
ProjectModelService,
|
|
11
|
+
ProjectService,
|
|
12
|
+
SecretService,
|
|
13
|
+
} from "@highstate/api/v1"
|
|
14
|
+
import { WorkerService } from "@highstate/api/worker.v1"
|
|
15
|
+
import { createInstanceStateService } from "./handlers/instance-state"
|
|
16
|
+
import { createLibraryService } from "./handlers/library"
|
|
17
|
+
import { createOperationService } from "./handlers/operation"
|
|
9
18
|
import { createPanelService } from "./handlers/panel"
|
|
19
|
+
import { createProjectService } from "./handlers/project"
|
|
20
|
+
import { createProjectModelService } from "./handlers/project-model"
|
|
10
21
|
import { createSecretService } from "./handlers/secret"
|
|
11
22
|
import { createWorkerService } from "./handlers/worker"
|
|
12
|
-
import {
|
|
23
|
+
import { createErrorHandlingInterceptor } from "./shared"
|
|
13
24
|
|
|
14
|
-
export
|
|
15
|
-
|
|
16
|
-
|
|
25
|
+
export type BackendApiOptions = {
|
|
26
|
+
address?: string
|
|
27
|
+
workerSocketPath?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type BackendApiHandlerOptions = {
|
|
31
|
+
requestPathPrefix?: string
|
|
32
|
+
}
|
|
17
33
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
34
|
+
export type BackendApi = {
|
|
35
|
+
address: string
|
|
36
|
+
shutdown(): Promise<void>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Creates a Node.js handler for the public Highstate Connect API.
|
|
41
|
+
*
|
|
42
|
+
* @param services The backend services used by the API handlers.
|
|
43
|
+
* @param options The API handler options.
|
|
44
|
+
* @returns A Node.js request listener serving the public API.
|
|
45
|
+
*/
|
|
46
|
+
export function createBackendApiHandler(
|
|
47
|
+
services: Services,
|
|
48
|
+
options: BackendApiHandlerOptions = {},
|
|
49
|
+
): RequestListener {
|
|
50
|
+
return connectNodeAdapter({
|
|
51
|
+
interceptors: [createErrorHandlingInterceptor(services)],
|
|
52
|
+
requestPathPrefix: options.requestPathPrefix,
|
|
53
|
+
routes(router) {
|
|
54
|
+
router.service(InstanceStateService, createInstanceStateService(services))
|
|
55
|
+
router.service(LibraryService, createLibraryService(services))
|
|
56
|
+
router.service(OperationService, createOperationService(services))
|
|
57
|
+
router.service(PanelService, createPanelService(services))
|
|
58
|
+
router.service(ProjectModelService, createProjectModelService(services))
|
|
59
|
+
router.service(ProjectService, createProjectService(services))
|
|
60
|
+
router.service(SecretService, createSecretService(services))
|
|
61
|
+
router.service(WorkerService, createWorkerService(services))
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
}
|
|
22
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Starts the public Highstate Connect API.
|
|
68
|
+
*
|
|
69
|
+
* @param services The backend services used by the API handlers.
|
|
70
|
+
* @param options The API listen and worker socket options.
|
|
71
|
+
* @returns The running API and its lifecycle controls.
|
|
72
|
+
*/
|
|
73
|
+
export async function startBackendApi(
|
|
74
|
+
services: Services,
|
|
75
|
+
options: BackendApiOptions = {},
|
|
76
|
+
): Promise<BackendApi> {
|
|
23
77
|
const uid = process.geteuid?.()
|
|
24
|
-
const
|
|
78
|
+
const workerSocketPath = options.workerSocketPath ?? `/run/user/${uid}/highstate.sock`
|
|
79
|
+
const workerAddress = `unix:${workerSocketPath}`
|
|
80
|
+
const address = options.address ?? workerAddress
|
|
81
|
+
const addresses = address === workerAddress ? [workerAddress] : [workerAddress, address]
|
|
82
|
+
const handler = createBackendApiHandler(services)
|
|
83
|
+
const servers = addresses.map(() => createServer(handler))
|
|
25
84
|
|
|
26
85
|
try {
|
|
27
|
-
await rm(
|
|
86
|
+
await rm(workerSocketPath, { force: true })
|
|
28
87
|
} catch (error) {
|
|
29
88
|
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) {
|
|
30
89
|
services.logger.error({ error }, "failed to remove existing socket file")
|
|
31
90
|
}
|
|
32
91
|
}
|
|
33
92
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
93
|
+
try {
|
|
94
|
+
for (const [index, server] of servers.entries()) {
|
|
95
|
+
const serverAddress = addresses[index]!
|
|
96
|
+
await listen(server, serverAddress)
|
|
97
|
+
services.logger.info(`api listening at "%s"`, serverAddress)
|
|
98
|
+
}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
await Promise.allSettled(servers.map(close))
|
|
101
|
+
await rm(workerSocketPath, { force: true })
|
|
102
|
+
throw new Error("Failed to start backend api", { cause: error })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
services.workerManager.config.HIGHSTATE_WORKER_API_PATH = workerSocketPath
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
address,
|
|
109
|
+
async shutdown() {
|
|
110
|
+
await Promise.all(servers.map(close))
|
|
111
|
+
await rm(workerSocketPath, { force: true })
|
|
112
|
+
},
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function listen(server: Server, address: string): Promise<void> {
|
|
117
|
+
await new Promise<void>((resolve, reject) => {
|
|
118
|
+
server.once("error", reject)
|
|
119
|
+
server.once("listening", resolve)
|
|
120
|
+
|
|
121
|
+
if (address.startsWith("unix:")) {
|
|
122
|
+
server.listen(address.slice("unix:".length))
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const url = new URL(address.includes("://") ? address : `http://${address}`)
|
|
127
|
+
server.listen(Number(url.port), url.hostname)
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function close(server: Server): Promise<void> {
|
|
132
|
+
await new Promise<void>((resolve, reject) => {
|
|
133
|
+
server.close(error => {
|
|
134
|
+
if (error) {
|
|
135
|
+
reject(error)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
resolve()
|
|
140
|
+
})
|
|
141
|
+
server.closeAllConnections()
|
|
142
|
+
})
|
|
37
143
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { GenMessage } from "@bufbuild/protobuf/codegenv2"
|
|
2
|
+
import { create, type Message } from "@bufbuild/protobuf"
|
|
3
|
+
import { type Code, ConnectError } from "@connectrpc/connect"
|
|
4
|
+
import { BadRequestSchema, ErrorInfoSchema } from "@highstate/api/v1"
|
|
5
|
+
|
|
6
|
+
export type ApiFieldViolation = {
|
|
7
|
+
field: string
|
|
8
|
+
reason: string
|
|
9
|
+
description: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type OutgoingDetail = { desc: GenMessage<Message>; value: Message }
|
|
13
|
+
|
|
14
|
+
export function createApiError(options: {
|
|
15
|
+
message: string
|
|
16
|
+
code: Code
|
|
17
|
+
reason: string
|
|
18
|
+
metadata?: Record<string, string>
|
|
19
|
+
fieldViolations?: readonly ApiFieldViolation[]
|
|
20
|
+
}): ConnectError {
|
|
21
|
+
const details: OutgoingDetail[] = [
|
|
22
|
+
{
|
|
23
|
+
desc: ErrorInfoSchema,
|
|
24
|
+
value: create(ErrorInfoSchema, {
|
|
25
|
+
reason: options.reason,
|
|
26
|
+
domain: "highstate.io",
|
|
27
|
+
metadata: options.metadata,
|
|
28
|
+
}),
|
|
29
|
+
},
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
if (options.fieldViolations?.length) {
|
|
33
|
+
details.push({
|
|
34
|
+
desc: BadRequestSchema,
|
|
35
|
+
value: create(BadRequestSchema, {
|
|
36
|
+
fieldViolations: options.fieldViolations.map(violation => ({ ...violation })),
|
|
37
|
+
}),
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return new ConnectError(options.message, options.code, undefined, details)
|
|
42
|
+
}
|
|
@@ -1,21 +1,181 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type {
|
|
2
|
+
ApiKey,
|
|
3
|
+
BackendApiKey,
|
|
4
|
+
BackendRequestContext,
|
|
5
|
+
ProjectRequestContext,
|
|
6
|
+
ResolvedPermissionGrant,
|
|
7
|
+
ResolvedPermissionRestriction,
|
|
8
|
+
ResolvedPermissions,
|
|
9
|
+
Services,
|
|
10
|
+
} from "@highstate/backend"
|
|
11
|
+
import type {
|
|
12
|
+
BackendPermission,
|
|
13
|
+
BackendPermissionRestriction,
|
|
14
|
+
ProjectPermission,
|
|
15
|
+
ProjectPermissionRestriction,
|
|
16
|
+
} from "@highstate/backend/shared"
|
|
17
|
+
import { Code, type HandlerContext } from "@connectrpc/connect"
|
|
18
|
+
import { ProjectLockedError, ProjectNotFoundError } from "@highstate/backend/shared"
|
|
19
|
+
import { createApiError } from "./api-error"
|
|
20
|
+
import { parseBearerToken } from "./authorization-header"
|
|
3
21
|
|
|
4
|
-
|
|
22
|
+
type ServiceAccountSubject = Extract<
|
|
23
|
+
ProjectRequestContext["subject"],
|
|
24
|
+
{ type: "service-account" }
|
|
25
|
+
> & { apiKeyId: string }
|
|
26
|
+
|
|
27
|
+
type BackendAuthorizationContext = Omit<BackendRequestContext, "subject"> & {
|
|
28
|
+
subject: ServiceAccountSubject
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type ProjectAuthorizationContext = Omit<ProjectRequestContext, "subject"> & {
|
|
32
|
+
subject: ServiceAccountSubject
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function authenticateBackend(
|
|
5
36
|
services: Services,
|
|
6
|
-
context:
|
|
7
|
-
): Promise<
|
|
8
|
-
const token = context
|
|
9
|
-
|
|
10
|
-
|
|
37
|
+
context: HandlerContext,
|
|
38
|
+
): Promise<BackendAuthorizationContext> {
|
|
39
|
+
const token = getBearerToken(context)
|
|
40
|
+
const apiKey = await services.apiKeyService.getBackendApiKeyByToken(token)
|
|
41
|
+
const roleBindings = await services.database.backend.serviceAccountBackendRoleBinding.findMany({
|
|
42
|
+
where: { serviceAccountId: apiKey.serviceAccountId },
|
|
43
|
+
include: { role: { select: { rules: true } } },
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
realm: "backend",
|
|
48
|
+
subject: {
|
|
49
|
+
type: "service-account",
|
|
50
|
+
serviceAccountId: apiKey.serviceAccountId,
|
|
51
|
+
apiKeyId: apiKey.id,
|
|
52
|
+
},
|
|
53
|
+
permissions: resolveBackendPermissions(
|
|
54
|
+
roleBindings.flatMap(binding =>
|
|
55
|
+
binding.role.rules.map(rule => ({
|
|
56
|
+
permissions: rule.permissions,
|
|
57
|
+
restrictions: rule.restrictions ?? [],
|
|
58
|
+
})),
|
|
59
|
+
),
|
|
60
|
+
apiKey.restrictionRules,
|
|
61
|
+
),
|
|
11
62
|
}
|
|
63
|
+
}
|
|
12
64
|
|
|
13
|
-
|
|
65
|
+
export async function authenticateProject(
|
|
66
|
+
services: Services,
|
|
67
|
+
request: { projectId: string },
|
|
68
|
+
context: HandlerContext,
|
|
69
|
+
): Promise<ProjectAuthorizationContext> {
|
|
70
|
+
const projectId = request.projectId
|
|
14
71
|
if (!projectId) {
|
|
15
|
-
throw
|
|
72
|
+
throw createApiError({
|
|
73
|
+
message: "No project ID provided",
|
|
74
|
+
code: Code.InvalidArgument,
|
|
75
|
+
reason: "PROJECT_ID_REQUIRED",
|
|
76
|
+
fieldViolations: [
|
|
77
|
+
{
|
|
78
|
+
field: "project_id",
|
|
79
|
+
reason: "REQUIRED",
|
|
80
|
+
description: "The project ID is required",
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const project = await services.database.backend.project.findUnique({
|
|
87
|
+
where: { id: projectId },
|
|
88
|
+
select: { id: true },
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
if (!project) {
|
|
92
|
+
throw new ProjectNotFoundError(projectId)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!(await services.projectUnlockService.checkProjectUnlocked(projectId))) {
|
|
96
|
+
throw new ProjectLockedError(projectId)
|
|
16
97
|
}
|
|
17
98
|
|
|
18
|
-
const
|
|
99
|
+
const token = getBearerToken(context)
|
|
100
|
+
const apiKey = await services.apiKeyService.getProjectCredentialByToken(projectId, token)
|
|
101
|
+
const projectDatabase = await services.database.forProject(projectId)
|
|
102
|
+
const roleBindings = await projectDatabase.serviceAccountRoleBinding.findMany({
|
|
103
|
+
where: { serviceAccountId: apiKey.serviceAccountId },
|
|
104
|
+
include: { role: { select: { rules: true } } },
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
realm: "project",
|
|
109
|
+
projectId,
|
|
110
|
+
subject: {
|
|
111
|
+
type: "service-account",
|
|
112
|
+
serviceAccountId: apiKey.serviceAccountId,
|
|
113
|
+
apiKeyId: apiKey.id,
|
|
114
|
+
},
|
|
115
|
+
permissions: resolveProjectPermissions(
|
|
116
|
+
roleBindings.flatMap(binding =>
|
|
117
|
+
binding.role.rules.map(rule => ({
|
|
118
|
+
permissions: rule.permissions,
|
|
119
|
+
restrictions: rule.restrictions ?? [],
|
|
120
|
+
})),
|
|
121
|
+
),
|
|
122
|
+
apiKey.restrictionRules,
|
|
123
|
+
),
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function getBearerToken(context: HandlerContext): string {
|
|
128
|
+
return parseBearerToken(context.requestHeader.get("authorization"))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function resolveBackendPermissions(
|
|
132
|
+
grants: readonly {
|
|
133
|
+
permissions: readonly BackendPermission[]
|
|
134
|
+
restrictions: readonly BackendPermissionRestriction[]
|
|
135
|
+
}[],
|
|
136
|
+
restrictions: BackendApiKey["restrictionRules"],
|
|
137
|
+
): ResolvedPermissions<BackendPermission, BackendPermissionRestriction> {
|
|
138
|
+
return resolvePermissions(grants, restrictions)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function resolveProjectPermissions(
|
|
142
|
+
grants: readonly {
|
|
143
|
+
permissions: readonly ProjectPermission[]
|
|
144
|
+
restrictions: readonly ProjectPermissionRestriction[]
|
|
145
|
+
}[],
|
|
146
|
+
restrictions: ApiKey["restrictionRules"],
|
|
147
|
+
): ResolvedPermissions<ProjectPermission, ProjectPermissionRestriction> {
|
|
148
|
+
return resolvePermissions(grants, restrictions)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function resolvePermissions<TPermission extends string, TRestriction>(
|
|
152
|
+
grants: readonly {
|
|
153
|
+
permissions: readonly TPermission[]
|
|
154
|
+
restrictions: readonly ResolvedPermissionRestriction<TRestriction>[]
|
|
155
|
+
}[],
|
|
156
|
+
restrictions: readonly {
|
|
157
|
+
permissions: readonly TPermission[]
|
|
158
|
+
restrictions?: readonly ResolvedPermissionRestriction<TRestriction>[]
|
|
159
|
+
}[],
|
|
160
|
+
): ResolvedPermissions<TPermission, TRestriction> {
|
|
161
|
+
const restrictionByPermission = new Map(
|
|
162
|
+
restrictions.flatMap(rule =>
|
|
163
|
+
rule.permissions.map(permission => [permission, rule.restrictions ?? []] as const),
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
const permissions = new Map<TPermission, ResolvedPermissionGrant<TRestriction>[]>()
|
|
167
|
+
for (const grant of grants) {
|
|
168
|
+
for (const permission of grant.permissions) {
|
|
169
|
+
const keyRestrictions = restrictionByPermission.get(permission)
|
|
170
|
+
if (restrictions.length > 0 && !keyRestrictions) {
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const permissionGrants = permissions.get(permission) ?? []
|
|
175
|
+
permissionGrants.push({ restrictions: [...grant.restrictions, ...(keyRestrictions ?? [])] })
|
|
176
|
+
permissions.set(permission, permissionGrants)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
19
179
|
|
|
20
|
-
return
|
|
180
|
+
return permissions
|
|
21
181
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Code } from "@connectrpc/connect"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { parseBearerToken } from "./authorization-header"
|
|
4
|
+
|
|
5
|
+
describe("parseBearerToken", () => {
|
|
6
|
+
it("accepts a case-insensitive Bearer scheme without normalizing the token", () => {
|
|
7
|
+
expect(parseBearerToken("Bearer hcp_key_secret")).toBe("hcp_key_secret")
|
|
8
|
+
expect(parseBearerToken("BEARER hcb_KEY_secret")).toBe("hcb_KEY_secret")
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it.each([
|
|
12
|
+
null,
|
|
13
|
+
"",
|
|
14
|
+
"hcp_key_secret",
|
|
15
|
+
"Basic hcp_key_secret",
|
|
16
|
+
"Bearer",
|
|
17
|
+
"Bearer ",
|
|
18
|
+
"Bearer token",
|
|
19
|
+
"Bearer token ",
|
|
20
|
+
" Bearer token",
|
|
21
|
+
"Bearer token value",
|
|
22
|
+
"Bearer\ttoken",
|
|
23
|
+
"Bearer\ntoken",
|
|
24
|
+
"Bearer token\n",
|
|
25
|
+
"Bearer token\u0000",
|
|
26
|
+
"Bearer token\u00a0value",
|
|
27
|
+
`Bearer ${"a".repeat(10_000)} value`,
|
|
28
|
+
])("rejects malformed authorization header %s", authorization => {
|
|
29
|
+
expect(() => parseBearerToken(authorization)).toThrowError(
|
|
30
|
+
expect.objectContaining({
|
|
31
|
+
code: Code.Unauthenticated,
|
|
32
|
+
rawMessage: "Invalid authorization header",
|
|
33
|
+
}),
|
|
34
|
+
)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Code } from "@connectrpc/connect"
|
|
2
|
+
import { createApiError } from "./api-error"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extracts a token from an HTTP Bearer authorization value.
|
|
6
|
+
*
|
|
7
|
+
* @param authorization The Authorization header value.
|
|
8
|
+
* @returns The unmodified Bearer token.
|
|
9
|
+
*/
|
|
10
|
+
export function parseBearerToken(authorization: string | null): string {
|
|
11
|
+
const match = /^Bearer ([^\s]+)$/i.exec(authorization ?? "")
|
|
12
|
+
if (
|
|
13
|
+
!match ||
|
|
14
|
+
[...match[1]!].some(character => character.charCodeAt(0) <= 0x1f || character === "\x7f")
|
|
15
|
+
) {
|
|
16
|
+
throw createApiError({
|
|
17
|
+
message: "Invalid authorization header",
|
|
18
|
+
code: Code.Unauthenticated,
|
|
19
|
+
reason: "AUTHORIZATION_HEADER_INVALID",
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return match[1]!
|
|
24
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { create } from "@bufbuild/protobuf"
|
|
2
|
+
import { timestampDate } from "@bufbuild/protobuf/wkt"
|
|
3
|
+
import { ComponentKind, InstanceSchema } from "@highstate/api/v1"
|
|
4
|
+
import { describe, expect, it } from "vitest"
|
|
5
|
+
import {
|
|
6
|
+
fromInstance,
|
|
7
|
+
toInstance,
|
|
8
|
+
toInstancePatch,
|
|
9
|
+
toNullableTimestamp,
|
|
10
|
+
toProjectModel,
|
|
11
|
+
toTimestamp,
|
|
12
|
+
} from "./conversion"
|
|
13
|
+
|
|
14
|
+
describe("resource conversion", () => {
|
|
15
|
+
it("converts instance dynamic values and references in both directions", () => {
|
|
16
|
+
const backend = {
|
|
17
|
+
id: "example.component.v1:main" as const,
|
|
18
|
+
kind: "unit" as const,
|
|
19
|
+
type: "example.component.v1" as const,
|
|
20
|
+
name: "main" as const,
|
|
21
|
+
args: { enabled: true, nested: { value: 1 } },
|
|
22
|
+
inputs: {
|
|
23
|
+
input: [{ instanceId: "example.source.v1:source" as const, output: "value" }],
|
|
24
|
+
},
|
|
25
|
+
position: { x: 10, y: 20 },
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const api = toInstance(backend)
|
|
29
|
+
|
|
30
|
+
expect(api.$typeName).toBe("io.highstate.v1.Instance")
|
|
31
|
+
expect(api.kind).toBe(ComponentKind.UNIT)
|
|
32
|
+
expect(fromInstance(api)).toEqual({
|
|
33
|
+
...backend,
|
|
34
|
+
hubInputs: {},
|
|
35
|
+
injectionInputs: [],
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("rejects an unspecified component kind", () => {
|
|
40
|
+
const instance = create(InstanceSchema, {
|
|
41
|
+
id: "example.component.v1:main",
|
|
42
|
+
type: "example.component.v1",
|
|
43
|
+
name: "main",
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
expect(() => fromInstance(instance)).toThrow("Component kind must be specified")
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("maps whole and nested position masks to backend patches", () => {
|
|
50
|
+
const instance = create(InstanceSchema, {
|
|
51
|
+
id: "example.component.v1:main",
|
|
52
|
+
kind: ComponentKind.UNIT,
|
|
53
|
+
type: "example.component.v1",
|
|
54
|
+
name: "main",
|
|
55
|
+
position: { x: 10, y: 20 },
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
expect(toInstancePatch(instance, ["position.x"])).toEqual({ position: { x: 10 } })
|
|
59
|
+
expect(toInstancePatch(instance, ["position"])).toEqual({ position: { x: 10, y: 20 } })
|
|
60
|
+
expect(toInstancePatch(create(InstanceSchema, instance), ["position"])).toEqual({
|
|
61
|
+
position: { x: 10, y: 20 },
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("clears a whole optional position", () => {
|
|
66
|
+
const instance = create(InstanceSchema, {
|
|
67
|
+
id: "example.component.v1:main",
|
|
68
|
+
kind: ComponentKind.UNIT,
|
|
69
|
+
type: "example.component.v1",
|
|
70
|
+
name: "main",
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
expect(toInstancePatch(instance, ["position"])).toEqual({ position: null })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("converts valid and nullable dates", () => {
|
|
77
|
+
const date = new Date("2026-08-24T12:34:56.789Z")
|
|
78
|
+
|
|
79
|
+
expect(timestampDate(toTimestamp(date))).toEqual(date)
|
|
80
|
+
expect(toNullableTimestamp(null)).toBeUndefined()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("rejects invalid dates", () => {
|
|
84
|
+
expect(() => toTimestamp(new Date(Number.NaN))).toThrow(
|
|
85
|
+
"Cannot convert invalid date to timestamp",
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("flattens requested virtual and ghost instances into the API project model", () => {
|
|
90
|
+
const instance = {
|
|
91
|
+
id: "example.component.v1:resident" as const,
|
|
92
|
+
kind: "unit" as const,
|
|
93
|
+
type: "example.component.v1" as const,
|
|
94
|
+
name: "resident" as const,
|
|
95
|
+
}
|
|
96
|
+
const model = toProjectModel({
|
|
97
|
+
instances: [instance],
|
|
98
|
+
virtualInstances: [
|
|
99
|
+
{
|
|
100
|
+
...instance,
|
|
101
|
+
id: "example.component.v1:virtual",
|
|
102
|
+
name: "virtual",
|
|
103
|
+
parentId: instance.id,
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
ghostInstances: [
|
|
107
|
+
{
|
|
108
|
+
...instance,
|
|
109
|
+
id: "example.component.v1:ghost",
|
|
110
|
+
name: "ghost",
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
hubs: [],
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
expect(model.instances.map(value => value.id)).toEqual([
|
|
117
|
+
"example.component.v1:resident",
|
|
118
|
+
"example.component.v1:virtual",
|
|
119
|
+
"example.component.v1:ghost",
|
|
120
|
+
])
|
|
121
|
+
})
|
|
122
|
+
})
|