@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
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { Code, ConnectError } from "@connectrpc/connect"
|
|
2
|
+
import { BadRequestSchema, ErrorInfoSchema } from "@highstate/api/v1"
|
|
3
|
+
import {
|
|
4
|
+
BackendError,
|
|
5
|
+
BackendErrorCategory,
|
|
6
|
+
InvalidOperationPlanError,
|
|
7
|
+
InvalidPageSizeError,
|
|
8
|
+
PermissionDeniedError,
|
|
9
|
+
} from "@highstate/backend/shared"
|
|
10
|
+
import { describe, expect, it, vi } from "vitest"
|
|
11
|
+
import { createErrorHandlingInterceptor } from "./error-handling"
|
|
12
|
+
|
|
13
|
+
describe("createErrorHandlingInterceptor", () => {
|
|
14
|
+
it("maps backend errors to structured details", async () => {
|
|
15
|
+
const logger = { error: vi.fn() }
|
|
16
|
+
const interceptor = createErrorHandlingInterceptor({ logger } as never)
|
|
17
|
+
const handler = interceptor(async () => {
|
|
18
|
+
throw new InvalidPageSizeError(-1)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
await handler({
|
|
23
|
+
method: { name: "ListOperations" },
|
|
24
|
+
header: new Headers({ "x-request-id": "request-1" }),
|
|
25
|
+
} as never)
|
|
26
|
+
expect.fail("Expected handler to throw")
|
|
27
|
+
} catch (error) {
|
|
28
|
+
expect(error).toBeInstanceOf(ConnectError)
|
|
29
|
+
const connectError = error as ConnectError
|
|
30
|
+
expect(connectError.code).toBe(Code.InvalidArgument)
|
|
31
|
+
expect(connectError.findDetails(ErrorInfoSchema)[0]?.reason).toBe("PAGE_SIZE_INVALID")
|
|
32
|
+
expect(connectError.findDetails(BadRequestSchema)[0]?.fieldViolations[0]?.field).toBe(
|
|
33
|
+
"page_size",
|
|
34
|
+
)
|
|
35
|
+
expect(logger.error).not.toHaveBeenCalled()
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("exposes operation planning validation errors", async () => {
|
|
40
|
+
const logger = { error: vi.fn() }
|
|
41
|
+
const interceptor = createErrorHandlingInterceptor({ logger } as never)
|
|
42
|
+
const handler = interceptor(async () => {
|
|
43
|
+
throw new InvalidOperationPlanError(
|
|
44
|
+
"Operation options are invalid: ghost options are supported only for updates",
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const result = (await handler({
|
|
49
|
+
method: { name: "PlanOperation" },
|
|
50
|
+
header: new Headers(),
|
|
51
|
+
} as never).catch(error => error as ConnectError)) as ConnectError
|
|
52
|
+
|
|
53
|
+
expect(result.code).toBe(Code.InvalidArgument)
|
|
54
|
+
expect(result.rawMessage).toBe(
|
|
55
|
+
"Operation options are invalid: ghost options are supported only for updates",
|
|
56
|
+
)
|
|
57
|
+
expect(result.findDetails(ErrorInfoSchema)[0]?.reason).toBe("OPERATION_PLAN_INVALID")
|
|
58
|
+
expect(logger.error).not.toHaveBeenCalled()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("sanitizes and logs unexpected errors once", async () => {
|
|
62
|
+
const logger = { error: vi.fn() }
|
|
63
|
+
const interceptor = createErrorHandlingInterceptor({ logger } as never)
|
|
64
|
+
const handler = interceptor(async () => {
|
|
65
|
+
throw new Error("sensitive internal path")
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
await expect(
|
|
69
|
+
handler({ method: { name: "GetProject" }, header: new Headers() } as never),
|
|
70
|
+
).rejects.toMatchObject({ code: Code.Internal, rawMessage: "An unexpected error occurred" })
|
|
71
|
+
expect(logger.error).toHaveBeenCalledOnce()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it("preserves cancellation without domain details", async () => {
|
|
75
|
+
const logger = { error: vi.fn() }
|
|
76
|
+
const cancellation = new ConnectError("Canceled", Code.Canceled)
|
|
77
|
+
const interceptor = createErrorHandlingInterceptor({ logger } as never)
|
|
78
|
+
const handler = interceptor(async () => {
|
|
79
|
+
throw cancellation
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
await expect(
|
|
83
|
+
handler({ method: { name: "Connect" }, header: new Headers() } as never),
|
|
84
|
+
).rejects.toBe(cancellation)
|
|
85
|
+
expect(logger.error).not.toHaveBeenCalled()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("preserves unauthenticated and permission-denied classifications without secret metadata", async () => {
|
|
89
|
+
for (const error of [
|
|
90
|
+
new BackendErrorForTest("Authentication required", BackendErrorCategory.Unauthenticated),
|
|
91
|
+
new PermissionDeniedError("project.get"),
|
|
92
|
+
]) {
|
|
93
|
+
const interceptor = createErrorHandlingInterceptor({ logger: { error: vi.fn() } } as never)
|
|
94
|
+
const handler = interceptor(async () => {
|
|
95
|
+
throw error
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
await expect(
|
|
99
|
+
handler({ method: { name: "GetProject" }, header: new Headers() } as never),
|
|
100
|
+
).rejects.toMatchObject({
|
|
101
|
+
code:
|
|
102
|
+
error.category === BackendErrorCategory.Unauthenticated
|
|
103
|
+
? Code.Unauthenticated
|
|
104
|
+
: Code.PermissionDenied,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const secret = new BackendErrorForTest(
|
|
109
|
+
"Authentication failed",
|
|
110
|
+
BackendErrorCategory.Unauthenticated,
|
|
111
|
+
{
|
|
112
|
+
token: "plaintext-token",
|
|
113
|
+
safe: "value",
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
const interceptor = createErrorHandlingInterceptor({ logger: { error: vi.fn() } } as never)
|
|
117
|
+
const handler = interceptor(async () => {
|
|
118
|
+
throw secret
|
|
119
|
+
})
|
|
120
|
+
const result = (await handler({
|
|
121
|
+
method: { name: "GetProject" },
|
|
122
|
+
header: new Headers(),
|
|
123
|
+
} as never).catch(error => error as ConnectError)) as ConnectError
|
|
124
|
+
|
|
125
|
+
expect(result.findDetails(ErrorInfoSchema)[0]?.metadata).toEqual({ safe: "value" })
|
|
126
|
+
expect(result.rawMessage).not.toContain("plaintext-token")
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
class BackendErrorForTest extends BackendError {
|
|
131
|
+
constructor(
|
|
132
|
+
message: string,
|
|
133
|
+
category: BackendErrorCategory,
|
|
134
|
+
metadata: Record<string, string> = {},
|
|
135
|
+
) {
|
|
136
|
+
super(message, { category, reason: "AUTHENTICATION_FAILED", metadata })
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -1,27 +1,140 @@
|
|
|
1
|
+
import type { GenMessage } from "@bufbuild/protobuf/codegenv2"
|
|
1
2
|
import type { Services } from "@highstate/backend"
|
|
2
|
-
import {
|
|
3
|
+
import { create, type Message } from "@bufbuild/protobuf"
|
|
4
|
+
import { durationFromMs } from "@bufbuild/protobuf/wkt"
|
|
5
|
+
import { Code, ConnectError, type Interceptor } from "@connectrpc/connect"
|
|
6
|
+
import {
|
|
7
|
+
BadRequestSchema,
|
|
8
|
+
ErrorInfoSchema,
|
|
9
|
+
PreconditionFailureSchema,
|
|
10
|
+
RequestInfoSchema,
|
|
11
|
+
RetryInfoSchema,
|
|
12
|
+
} from "@highstate/api/v1"
|
|
13
|
+
import { BackendError, BackendErrorCategory } from "@highstate/backend/shared"
|
|
3
14
|
import { isAbortError } from "abort-controller-x"
|
|
4
|
-
import { type CallContext, ServerError, type ServerMiddlewareCall, Status } from "nice-grpc-common"
|
|
5
15
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
16
|
+
const sensitiveMetadataKey = /credential|token|secret|encrypted|cause|stack|path/i
|
|
17
|
+
|
|
18
|
+
type OutgoingDetail = { desc: GenMessage<Message>; value: Message }
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Creates the interceptor that translates backend failures into API errors.
|
|
22
|
+
*
|
|
23
|
+
* @param services The backend services used for error logging.
|
|
24
|
+
* @returns An interceptor that translates backend failures into API errors.
|
|
25
|
+
*/
|
|
26
|
+
export function createErrorHandlingInterceptor(services: Services): Interceptor {
|
|
27
|
+
return next => async request => {
|
|
11
28
|
try {
|
|
12
|
-
return
|
|
29
|
+
return await next(request)
|
|
13
30
|
} catch (error) {
|
|
14
|
-
if (error instanceof
|
|
31
|
+
if (isAbortError(error) || (error instanceof ConnectError && error.code === Code.Canceled)) {
|
|
15
32
|
throw error
|
|
16
33
|
}
|
|
17
34
|
|
|
18
|
-
if (error instanceof
|
|
19
|
-
|
|
20
|
-
|
|
35
|
+
if (error instanceof BackendError) {
|
|
36
|
+
const code = categoryToCode(error.category)
|
|
37
|
+
if (code === Code.Internal) {
|
|
38
|
+
services.logger.error({ error, method: request.method.name }, "unexpected backend error")
|
|
39
|
+
throw new ConnectError("An unexpected error occurred", Code.Internal)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
throw new ConnectError(error.message, code, undefined, backendErrorDetails(error, request))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (error instanceof ConnectError) {
|
|
46
|
+
throw error
|
|
21
47
|
}
|
|
22
48
|
|
|
23
|
-
services.logger.error({ error }, "unexpected error")
|
|
24
|
-
throw new
|
|
49
|
+
services.logger.error({ error, method: request.method.name }, "unexpected error")
|
|
50
|
+
throw new ConnectError("An unexpected error occurred", Code.Internal)
|
|
25
51
|
}
|
|
26
52
|
}
|
|
27
53
|
}
|
|
54
|
+
|
|
55
|
+
function backendErrorDetails(
|
|
56
|
+
error: BackendError,
|
|
57
|
+
request: Parameters<Parameters<Interceptor>[0]>[0],
|
|
58
|
+
): OutgoingDetail[] {
|
|
59
|
+
const details: OutgoingDetail[] = [
|
|
60
|
+
{
|
|
61
|
+
desc: ErrorInfoSchema,
|
|
62
|
+
value: create(ErrorInfoSchema, {
|
|
63
|
+
reason: error.reason,
|
|
64
|
+
domain: "highstate.io",
|
|
65
|
+
metadata: Object.fromEntries(
|
|
66
|
+
Object.entries(error.metadata).filter(([key]) => !sensitiveMetadataKey.test(key)),
|
|
67
|
+
),
|
|
68
|
+
}),
|
|
69
|
+
},
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
if (error.fieldViolations.length > 0) {
|
|
73
|
+
details.push({
|
|
74
|
+
desc: BadRequestSchema,
|
|
75
|
+
value: create(BadRequestSchema, {
|
|
76
|
+
fieldViolations: error.fieldViolations.map(violation => ({
|
|
77
|
+
field: toProtobufPath(violation.field),
|
|
78
|
+
reason: violation.reason,
|
|
79
|
+
description: violation.description,
|
|
80
|
+
})),
|
|
81
|
+
}),
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (error.preconditionViolations.length > 0) {
|
|
86
|
+
details.push({
|
|
87
|
+
desc: PreconditionFailureSchema,
|
|
88
|
+
value: create(PreconditionFailureSchema, {
|
|
89
|
+
violations: error.preconditionViolations.map(violation => ({ ...violation })),
|
|
90
|
+
}),
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (error.retry && Number.isFinite(error.retry.delayMs) && error.retry.delayMs >= 0) {
|
|
95
|
+
details.push({
|
|
96
|
+
desc: RetryInfoSchema,
|
|
97
|
+
value: create(RetryInfoSchema, { retryDelay: durationFromMs(error.retry.delayMs) }),
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const requestId = request.header.get("x-request-id")?.trim()
|
|
102
|
+
if (requestId) {
|
|
103
|
+
details.push({
|
|
104
|
+
desc: RequestInfoSchema,
|
|
105
|
+
value: create(RequestInfoSchema, { requestId }),
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return details
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function toProtobufPath(path: string): string {
|
|
113
|
+
return path
|
|
114
|
+
.split(".")
|
|
115
|
+
.map(segment => segment.replace(/[A-Z]/g, character => `_${character.toLowerCase()}`))
|
|
116
|
+
.join(".")
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function categoryToCode(category: BackendErrorCategory): Code {
|
|
120
|
+
switch (category) {
|
|
121
|
+
case BackendErrorCategory.InvalidArgument:
|
|
122
|
+
return Code.InvalidArgument
|
|
123
|
+
case BackendErrorCategory.Unauthenticated:
|
|
124
|
+
return Code.Unauthenticated
|
|
125
|
+
case BackendErrorCategory.PermissionDenied:
|
|
126
|
+
return Code.PermissionDenied
|
|
127
|
+
case BackendErrorCategory.NotFound:
|
|
128
|
+
return Code.NotFound
|
|
129
|
+
case BackendErrorCategory.AlreadyExists:
|
|
130
|
+
return Code.AlreadyExists
|
|
131
|
+
case BackendErrorCategory.FailedPrecondition:
|
|
132
|
+
return Code.FailedPrecondition
|
|
133
|
+
case BackendErrorCategory.Aborted:
|
|
134
|
+
return Code.Aborted
|
|
135
|
+
case BackendErrorCategory.Unavailable:
|
|
136
|
+
return Code.Unavailable
|
|
137
|
+
case BackendErrorCategory.Internal:
|
|
138
|
+
return Code.Internal
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { create } from "@bufbuild/protobuf"
|
|
2
|
+
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt"
|
|
3
|
+
import { ConnectError } from "@connectrpc/connect"
|
|
4
|
+
import { BadRequestSchema, InstanceSchema } from "@highstate/api/v1"
|
|
5
|
+
import { describe, expect, it } from "vitest"
|
|
6
|
+
import { validateUpdateMask } from "./field-mask"
|
|
7
|
+
|
|
8
|
+
const mutablePaths = new Set(["arguments", "position", "position.x", "position.y"])
|
|
9
|
+
|
|
10
|
+
describe("validateUpdateMask", () => {
|
|
11
|
+
it("normalizes JSON and TypeScript field names to Protobuf paths", () => {
|
|
12
|
+
const paths = validateUpdateMask(
|
|
13
|
+
create(FieldMaskSchema, { paths: ["position.x", "arguments"] }),
|
|
14
|
+
InstanceSchema,
|
|
15
|
+
mutablePaths,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
expect(paths).toEqual(["position.x", "arguments"])
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it.each([
|
|
22
|
+
{ paths: [] },
|
|
23
|
+
{ paths: ["*"] },
|
|
24
|
+
{ paths: ["id"] },
|
|
25
|
+
{ paths: ["inputs.values"] },
|
|
26
|
+
{ paths: ["arguments.value"] },
|
|
27
|
+
])("rejects invalid paths %j", ({ paths }) => {
|
|
28
|
+
try {
|
|
29
|
+
validateUpdateMask(create(FieldMaskSchema, { paths }), InstanceSchema, mutablePaths)
|
|
30
|
+
expect.fail("Expected update mask validation to fail")
|
|
31
|
+
} catch (error) {
|
|
32
|
+
expect(error).toBeInstanceOf(ConnectError)
|
|
33
|
+
const connectError = error as ConnectError
|
|
34
|
+
expect(connectError.findDetails(BadRequestSchema)).toHaveLength(1)
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Message } from "@bufbuild/protobuf"
|
|
2
|
+
import type { GenMessage } from "@bufbuild/protobuf/codegenv2"
|
|
3
|
+
import type { FieldMask } from "@bufbuild/protobuf/wkt"
|
|
4
|
+
import { Code, type ConnectError } from "@connectrpc/connect"
|
|
5
|
+
import { createApiError } from "./api-error"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Validates and normalizes a request update mask.
|
|
9
|
+
*
|
|
10
|
+
* @param mask The update mask to validate.
|
|
11
|
+
* @param schema The protobuf schema used to normalize field names.
|
|
12
|
+
* @param mutablePaths The set of fields that may be updated.
|
|
13
|
+
* @returns The normalized mutable field paths.
|
|
14
|
+
*/
|
|
15
|
+
export function validateUpdateMask<T extends Message>(
|
|
16
|
+
mask: FieldMask | undefined,
|
|
17
|
+
schema: GenMessage<T>,
|
|
18
|
+
mutablePaths: ReadonlySet<string>,
|
|
19
|
+
): string[] {
|
|
20
|
+
if (!mask || mask.paths.length === 0) {
|
|
21
|
+
throw fieldMaskError("update_mask", "REQUIRED", "The update mask must not be empty")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const paths = new Set<string>()
|
|
25
|
+
for (const path of mask.paths) {
|
|
26
|
+
const normalized = normalizePath(schema, path)
|
|
27
|
+
if (!mutablePaths.has(normalized)) {
|
|
28
|
+
throw fieldMaskError(
|
|
29
|
+
`update_mask.paths`,
|
|
30
|
+
"IMMUTABLE_OR_UNKNOWN",
|
|
31
|
+
`The path "${path}" is unknown, immutable, output-only, or traverses a collection`,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
paths.add(normalized)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return [...paths]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizePath<T extends Message>(schema: GenMessage<T>, path: string): string {
|
|
41
|
+
if (!path || path === "*") return path
|
|
42
|
+
|
|
43
|
+
let descriptor = schema
|
|
44
|
+
const normalized: string[] = []
|
|
45
|
+
const segments = path.split(".")
|
|
46
|
+
|
|
47
|
+
for (const [index, segment] of segments.entries()) {
|
|
48
|
+
const field = descriptor.fields.find(
|
|
49
|
+
candidate =>
|
|
50
|
+
candidate.name === segment ||
|
|
51
|
+
candidate.localName === segment ||
|
|
52
|
+
candidate.jsonName === segment,
|
|
53
|
+
)
|
|
54
|
+
if (!field) return path
|
|
55
|
+
|
|
56
|
+
normalized.push(field.name)
|
|
57
|
+
if (index === segments.length - 1) break
|
|
58
|
+
if (field.fieldKind !== "message" || !field.message) return path
|
|
59
|
+
|
|
60
|
+
descriptor = field.message as GenMessage<T>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return normalized.join(".")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Creates an API error describing an invalid update-mask field.
|
|
68
|
+
*
|
|
69
|
+
* @param field The field containing the violation.
|
|
70
|
+
* @param reason The reason the field is invalid.
|
|
71
|
+
* @param description The human-readable explanation of the violation.
|
|
72
|
+
* @returns The API error describing the invalid field.
|
|
73
|
+
*/
|
|
74
|
+
export function fieldMaskError(field: string, reason: string, description: string): ConnectError {
|
|
75
|
+
return createApiError({
|
|
76
|
+
message: "Invalid update mask",
|
|
77
|
+
code: Code.InvalidArgument,
|
|
78
|
+
reason: "UPDATE_MASK_INVALID",
|
|
79
|
+
fieldViolations: [{ field, reason, description }],
|
|
80
|
+
})
|
|
81
|
+
}
|
package/src/shared/index.ts
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { toJsonObject } from "./serialization"
|
|
3
|
+
|
|
4
|
+
describe("toJsonObject", () => {
|
|
5
|
+
it("normalizes dates and removes undefined values", () => {
|
|
6
|
+
expect(
|
|
7
|
+
toJsonObject({
|
|
8
|
+
createdAt: new Date("2026-08-22T12:00:00.000Z"),
|
|
9
|
+
omitted: undefined,
|
|
10
|
+
nested: [{ value: 1 }],
|
|
11
|
+
}),
|
|
12
|
+
).toEqual({
|
|
13
|
+
createdAt: "2026-08-22T12:00:00.000Z",
|
|
14
|
+
nested: [{ value: 1 }],
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { JsonObject } from "@bufbuild/protobuf"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Converts a structured value into a protobuf JSON object.
|
|
5
|
+
*
|
|
6
|
+
* @param value The structured value to convert.
|
|
7
|
+
* @returns The corresponding protobuf JSON object.
|
|
8
|
+
*/
|
|
9
|
+
export function toJsonObject(value: object): JsonObject {
|
|
10
|
+
return normalizeJsonValue(value) as JsonObject
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeJsonValue(value: unknown): unknown {
|
|
14
|
+
if (value instanceof Date) {
|
|
15
|
+
return value.toISOString()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (Array.isArray(value)) {
|
|
19
|
+
return value.map(normalizeJsonValue)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (value && typeof value === "object") {
|
|
23
|
+
return Object.fromEntries(
|
|
24
|
+
Object.entries(value).flatMap(([key, entry]) => {
|
|
25
|
+
if (entry === undefined) {
|
|
26
|
+
return []
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return [[key, normalizeJsonValue(entry)]]
|
|
30
|
+
}),
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return value
|
|
35
|
+
}
|
package/src/shared/validation.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import type { z } from "zod"
|
|
2
|
-
import {
|
|
2
|
+
import { Code } from "@connectrpc/connect"
|
|
3
|
+
import { createApiError } from "./api-error"
|
|
3
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Parses and validates a named request argument.
|
|
7
|
+
*
|
|
8
|
+
* @param request The request containing the argument.
|
|
9
|
+
* @param argumentName The name of the argument to validate.
|
|
10
|
+
* @param schema The schema used to validate the argument.
|
|
11
|
+
* @returns The validated argument value.
|
|
12
|
+
*/
|
|
4
13
|
export function parseArgument<
|
|
5
14
|
TRequest,
|
|
6
15
|
TArgumentName extends string & keyof TRequest,
|
|
@@ -8,11 +17,42 @@ export function parseArgument<
|
|
|
8
17
|
>(request: TRequest, argumentName: TArgumentName, schema: TSchema): z.infer<TSchema> {
|
|
9
18
|
const result = schema.safeParse(request[argumentName])
|
|
10
19
|
if (!result.success) {
|
|
11
|
-
throw
|
|
12
|
-
Status.INVALID_ARGUMENT,
|
|
13
|
-
`Invalid argument "${argumentName}": ${result.error.message}`,
|
|
14
|
-
)
|
|
20
|
+
throw validationError(`Invalid argument "${argumentName}"`, argumentName, result.error)
|
|
15
21
|
}
|
|
16
22
|
|
|
17
23
|
return result.data
|
|
18
24
|
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parses and validates an arbitrary value.
|
|
28
|
+
*
|
|
29
|
+
* @param value The value to validate.
|
|
30
|
+
* @param name The name used in validation errors.
|
|
31
|
+
* @param schema The schema used to validate the value.
|
|
32
|
+
* @returns The validated value.
|
|
33
|
+
*/
|
|
34
|
+
export function parseValue<TSchema extends z.ZodType>(
|
|
35
|
+
value: unknown,
|
|
36
|
+
name: string,
|
|
37
|
+
schema: TSchema,
|
|
38
|
+
): z.infer<TSchema> {
|
|
39
|
+
const result = schema.safeParse(value)
|
|
40
|
+
if (!result.success) {
|
|
41
|
+
throw validationError(`Invalid ${name}`, name, result.error)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return result.data
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validationError(message: string, field: string, error: z.ZodError) {
|
|
48
|
+
return createApiError({
|
|
49
|
+
message,
|
|
50
|
+
code: Code.InvalidArgument,
|
|
51
|
+
reason: "REQUEST_INVALID",
|
|
52
|
+
fieldViolations: error.issues.map(issue => ({
|
|
53
|
+
field: [field, ...issue.path.map(String)].join("."),
|
|
54
|
+
reason: issue.code.toUpperCase(),
|
|
55
|
+
description: issue.message,
|
|
56
|
+
})),
|
|
57
|
+
})
|
|
58
|
+
}
|
package/src/handlers/instance.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import type { InstanceServiceImplementation } from "@highstate/api/instance.v1"
|
|
2
|
-
import type { Services } from "@highstate/backend"
|
|
3
|
-
import { instanceCustomStatusInputSchema } from "@highstate/backend/shared"
|
|
4
|
-
import { z } from "@highstate/contract"
|
|
5
|
-
import { authenticate, parseArgument } from "../shared"
|
|
6
|
-
|
|
7
|
-
export function createInstanceService(services: Services): InstanceServiceImplementation {
|
|
8
|
-
return {
|
|
9
|
-
async updateCustomStatus(request, context) {
|
|
10
|
-
const [projectId, apiKey] = await authenticate(services, context)
|
|
11
|
-
|
|
12
|
-
// TODO: validate instance access
|
|
13
|
-
|
|
14
|
-
const stateId = parseArgument(request, "stateId", z.cuid2())
|
|
15
|
-
const customStatus = parseArgument(request, "status", instanceCustomStatusInputSchema)
|
|
16
|
-
|
|
17
|
-
await services.instanceStateService.updateCustomStatus(
|
|
18
|
-
projectId,
|
|
19
|
-
stateId,
|
|
20
|
-
apiKey.serviceAccountId,
|
|
21
|
-
customStatus,
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
return {}
|
|
25
|
-
},
|
|
26
|
-
|
|
27
|
-
async removeCustomStatus(request, context) {
|
|
28
|
-
const [projectId, apiKey] = await authenticate(services, context)
|
|
29
|
-
|
|
30
|
-
// TODO: validate instance access
|
|
31
|
-
|
|
32
|
-
const stateId = parseArgument(request, "stateId", z.cuid2())
|
|
33
|
-
|
|
34
|
-
await services.instanceStateService.removeCustomStatus(
|
|
35
|
-
projectId,
|
|
36
|
-
stateId,
|
|
37
|
-
apiKey.serviceAccountId,
|
|
38
|
-
request.statusName,
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
return {}
|
|
42
|
-
},
|
|
43
|
-
}
|
|
44
|
-
}
|