Skip to content

Commit f25cc5e

Browse files
committed
fix(app): address OpenAPI review follow-ups
1 parent a0f08e5 commit f25cc5e

8 files changed

Lines changed: 161 additions & 21 deletions

File tree

bun.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/api/tests/openapi.test.ts

Lines changed: 60 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
11
import { describe, expect, it } from "@effect/vitest"
22
import { Effect } from "effect"
3+
import * as fc from "fast-check"
34

45
import { buildDockerGitOpenApi } from "../src/api/openapi.js"
56

67
const documentedMethods = ["delete", "get", "post", "put"] as const
78
const commonErrorStatuses = ["400", "401", "404", "409", "500"]
9+
const okOnlyOperations = [
10+
{ method: "post", path: "/projects/apply-all" },
11+
{ method: "post", path: "/projects/down-all" },
12+
{ method: "delete", path: "/projects/{projectId}" },
13+
{ method: "post", path: "/projects/{projectId}/down" },
14+
{ method: "delete", path: "/projects/{projectId}/ports/{targetPort}" },
15+
{ method: "delete", path: "/projects/{projectId}/databases/profiles/{profileId}" },
16+
{ method: "delete", path: "/projects/{projectId}/databases/profiles/{profileId}/expose" },
17+
{ method: "delete", path: "/projects/by-key/{projectKey}/terminal-sessions/{sessionId}" },
18+
{ method: "delete", path: "/auth/terminal-sessions/{sessionId}" },
19+
{ method: "post", path: "/projects/{projectId}/tasks/{pid}/stop" }
20+
] as const
821

922
describe("openapi contract", () => {
1023
it.effect("documents generated REST paths from the Effect HttpApi contract", () =>
@@ -86,22 +99,38 @@ describe("openapi contract", () => {
8699
}
87100
}))
88101

102+
it.effect("preserves common error status invariant for arbitrary documented operations", () =>
103+
Effect.sync(() => {
104+
const spec = buildDockerGitOpenApi()
105+
const paths = spec.paths ?? {}
106+
const operations = Object.entries(paths).flatMap(([path, item]) =>
107+
documentedMethods.flatMap((method) => {
108+
const responses = item[method]?.responses
109+
return responses === undefined ? [] : [{ method, path, responses }]
110+
})
111+
)
112+
113+
expect(operations.length).toBeGreaterThan(0)
114+
115+
fc.assert(
116+
fc.property(fc.integer({ min: 0, max: operations.length - 1 }), (index) => {
117+
const operation = operations[index]
118+
if (operation === undefined) {
119+
return false
120+
}
121+
expect(Object.keys(operation.responses), `${operation.method.toUpperCase()} ${operation.path}`).toEqual(
122+
expect.arrayContaining(commonErrorStatuses)
123+
)
124+
return true
125+
}),
126+
{ numRuns: Math.max(operations.length * 2, 50) }
127+
)
128+
}))
129+
89130
it.effect("documents ok-only HTTP handlers as 200 JSON responses", () =>
90131
Effect.sync(() => {
91132
const spec = buildDockerGitOpenApi()
92133
const paths = spec.paths ?? {}
93-
const okOnlyOperations = [
94-
{ method: "post", path: "/projects/apply-all" },
95-
{ method: "post", path: "/projects/down-all" },
96-
{ method: "delete", path: "/projects/{projectId}" },
97-
{ method: "post", path: "/projects/{projectId}/down" },
98-
{ method: "delete", path: "/projects/{projectId}/ports/{targetPort}" },
99-
{ method: "delete", path: "/projects/{projectId}/databases/profiles/{profileId}" },
100-
{ method: "delete", path: "/projects/{projectId}/databases/profiles/{profileId}/expose" },
101-
{ method: "delete", path: "/projects/by-key/{projectKey}/terminal-sessions/{sessionId}" },
102-
{ method: "delete", path: "/auth/terminal-sessions/{sessionId}" },
103-
{ method: "post", path: "/projects/{projectId}/tasks/{pid}/stop" }
104-
] as const
105134

106135
for (const operation of okOnlyOperations) {
107136
const responses = paths[operation.path]?.[operation.method]?.responses ?? {}
@@ -114,6 +143,25 @@ describe("openapi contract", () => {
114143
}
115144
}))
116145

146+
it.effect("preserves ok-only handler response invariant for arbitrary ok-only operations", () =>
147+
Effect.sync(() => {
148+
const spec = buildDockerGitOpenApi()
149+
const paths = spec.paths ?? {}
150+
151+
fc.assert(
152+
fc.property(fc.constantFrom(...okOnlyOperations), (operation) => {
153+
const responses = paths[operation.path]?.[operation.method]?.responses ?? {}
154+
const serializedSuccessSchema = JSON.stringify(responses["200"] ?? {})
155+
156+
expect(responses["200"], `${operation.method.toUpperCase()} ${operation.path}`).toBeDefined()
157+
expect(responses["204"], `${operation.method.toUpperCase()} ${operation.path}`).toBeUndefined()
158+
expect(serializedSuccessSchema).toContain("\"required\":[\"ok\"]")
159+
expect(serializedSuccessSchema).toContain("\"ok\":{\"type\":\"boolean\",\"enum\":[true]}")
160+
}),
161+
{ numRuns: okOnlyOperations.length * 2 }
162+
)
163+
}))
164+
117165
it.effect("documents project auth snapshots without nonexistent totalEntries", () =>
118166
Effect.sync(() => {
119167
const spec = buildDockerGitOpenApi()

packages/app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
"@effect/workflow": "^0.18.2",
8080
"@gridland/bun": "0.4.3",
8181
"@gridland/web": "0.4.3",
82+
"@prover-coder-ai/docker-git-openapi": "workspace:*",
8283
"@prover-coder-ai/docker-git-session-sync": "workspace:*",
8384
"effect": "^3.21.3",
8485
"react": "19.2.4",
@@ -95,7 +96,6 @@
9596
"@eslint/compat": "2.1.0",
9697
"@eslint/eslintrc": "3.3.5",
9798
"@eslint/js": "10.0.1",
98-
"@prover-coder-ai/docker-git-openapi": "workspace:*",
9999
"@prover-coder-ai/docker-git-terminal": "workspace:*",
100100
"@prover-coder-ai/eslint-plugin-suggest-members": "^0.0.26",
101101
"@ton-ai-core/vibecode-linter": "^1.0.11",

packages/app/src/web/api-create-project.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
11
import type { Effect } from "effect"
22

33
import {
4+
type BaseCreateProjectBody,
45
baseCreateProjectBody,
56
type CreateProjectRequestDraft,
6-
optionalProjectResourceFields
7+
optionalProjectResourceFields,
8+
type OptionalProjectResourceFieldsBody
79
} from "./api-project-create-body.js"
810
import { CreateProjectAcceptedResponseSchema } from "./api-schema.js"
911
import type { CreateProjectAcceptedResponse } from "./api-schema.js"
1012
import { openApiJsonSchema } from "./openapi-client.js"
1113

14+
type CreateProjectAcceptedBody = Readonly<
15+
& BaseCreateProjectBody
16+
& OptionalProjectResourceFieldsBody
17+
& {
18+
readonly async: true
19+
}
20+
>
21+
1222
/**
1323
* Builds the async POST /projects request body.
1424
*
@@ -23,7 +33,7 @@ import { openApiJsonSchema } from "./openapi-client.js"
2333
* @complexity O(1).
2434
* @throws Never.
2535
*/
26-
export const createProjectAcceptedBody = (draft: CreateProjectRequestDraft) => ({
36+
export const createProjectAcceptedBody = (draft: CreateProjectRequestDraft): CreateProjectAcceptedBody => ({
2737
...baseCreateProjectBody(draft),
2838
async: true,
2939
...optionalProjectResourceFields(draft)

packages/app/src/web/api-project-create-body.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,48 @@ import type { CreateProjectDraft } from "./api-schema.js"
1414
*/
1515
export type CreateProjectRequestDraft = CreateProjectDraft & ProjectResourceLimitRequest
1616

17+
/**
18+
* Optional resource-limit fragment serialized into POST /projects request bodies.
19+
*
20+
* @pure true - structural type contract only.
21+
* @effect none
22+
* @invariant present fields are defined strings; undefined inputs are omitted by optionalProjectResourceFields.
23+
* @precondition callers construct this value through optionalProjectResourceFields.
24+
* @postcondition spreading this fragment cannot add undefined Playwright limit fields.
25+
* @complexity O(1).
26+
* @throws Never.
27+
*/
28+
export type OptionalProjectResourceFieldsBody = Readonly<{
29+
readonly playwrightCpuLimit?: Exclude<ProjectResourceLimitRequest["playwrightCpuLimit"], undefined>
30+
readonly playwrightRamLimit?: Exclude<ProjectResourceLimitRequest["playwrightRamLimit"], undefined>
31+
}>
32+
33+
/**
34+
* Common POST /projects request body shared by synchronous and asynchronous create flows.
35+
*
36+
* @pure true - structural type contract only.
37+
* @effect none
38+
* @invariant sync and async create flows share identical non-async fields.
39+
* @precondition callers construct this value through baseCreateProjectBody.
40+
* @postcondition openSsh is false and managed authorized keys are enabled for web-created projects.
41+
* @complexity O(1).
42+
* @throws Never.
43+
*/
44+
export type BaseCreateProjectBody = Readonly<{
45+
readonly cpuLimit: CreateProjectDraft["cpuLimit"]
46+
readonly enableMcpPlaywright: CreateProjectDraft["enableMcpPlaywright"]
47+
readonly force: CreateProjectDraft["force"]
48+
readonly forceEnv: CreateProjectDraft["forceEnv"]
49+
readonly gpu: CreateProjectDraft["gpu"]
50+
readonly openSsh: false
51+
readonly outDir: CreateProjectDraft["outDir"]
52+
readonly ramLimit: CreateProjectDraft["ramLimit"]
53+
readonly repoRef: CreateProjectDraft["repoRef"]
54+
readonly repoUrl: CreateProjectDraft["repoUrl"]
55+
readonly up: CreateProjectDraft["up"]
56+
readonly useManagedAuthorizedKeys: true
57+
}>
58+
1759
/**
1860
* Serializes optional Playwright resource limits for project creation requests.
1961
*
@@ -28,7 +70,9 @@ export type CreateProjectRequestDraft = CreateProjectDraft & ProjectResourceLimi
2870
* @complexity O(1).
2971
* @throws Never.
3072
*/
31-
export const optionalProjectResourceFields = (request: ProjectResourceLimitRequest) => ({
73+
export const optionalProjectResourceFields = (
74+
request: ProjectResourceLimitRequest
75+
): OptionalProjectResourceFieldsBody => ({
3276
...(request.playwrightCpuLimit !== undefined && { playwrightCpuLimit: request.playwrightCpuLimit }),
3377
...(request.playwrightRamLimit !== undefined && { playwrightRamLimit: request.playwrightRamLimit })
3478
})
@@ -47,7 +91,7 @@ export const optionalProjectResourceFields = (request: ProjectResourceLimitReque
4791
* @complexity O(1).
4892
* @throws Never.
4993
*/
50-
export const baseCreateProjectBody = (draft: CreateProjectDraft) => ({
94+
export const baseCreateProjectBody = (draft: CreateProjectDraft): BaseCreateProjectBody => ({
5195
cpuLimit: draft.cpuLimit,
5296
enableMcpPlaywright: draft.enableMcpPlaywright,
5397
force: draft.force,

packages/app/src/web/openapi-client.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const openApiRuntime = makeDockerGitOpenApiRuntime({
1717
* @complexity O(n)/O(n) for error rendering, O(1)/O(1) on local success handling.
1818
* @throws Never; failures are returned in the Effect error channel.
1919
*/
20-
export const openApiJson = openApiRuntime.openApiJson
20+
export const openApiJson: typeof openApiRuntime.openApiJson = openApiRuntime.openApiJson
2121

2222
/**
2323
* Executes a docker-git OpenAPI request and decodes the response with an Effect Schema.
@@ -30,7 +30,7 @@ export const openApiJson = openApiRuntime.openApiJson
3030
* @complexity O(n)/O(n) where n is the decoded response size.
3131
* @throws Never; failures are returned in the Effect error channel.
3232
*/
33-
export const openApiJsonSchema = openApiRuntime.openApiJsonSchema
33+
export const openApiJsonSchema: typeof openApiRuntime.openApiJsonSchema = openApiRuntime.openApiJsonSchema
3434

3535
/**
3636
* Executes a docker-git OpenAPI request whose success response has no body.
@@ -43,4 +43,4 @@ export const openApiJsonSchema = openApiRuntime.openApiJsonSchema
4343
* @complexity O(n)/O(n) for error rendering, O(1)/O(1) on local success handling.
4444
* @throws Never; failures are returned in the Effect error channel.
4545
*/
46-
export const openApiVoid = openApiRuntime.openApiVoid
46+
export const openApiVoid: typeof openApiRuntime.openApiVoid = openApiRuntime.openApiVoid

packages/app/tests/docker-git/api-create-project.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from "@effect/vitest"
22
import { Effect } from "effect"
3+
import * as fc from "fast-check"
34

45
import { createProjectAcceptedBody } from "../../src/web/api-create-project.js"
56
import type { CreateProjectRequestDraft } from "../../src/web/api-project-create-body.js"
@@ -19,7 +20,41 @@ const projectDraft = {
1920
up: true
2021
} satisfies CreateProjectRequestDraft
2122

23+
const hasOwnField = (value: object, key: string): boolean => Object.prototype.hasOwnProperty.call(value, key)
24+
const optionalPlaywrightLimitArbitrary = fc.option(fc.string(), { nil: undefined })
25+
2226
describe("api create project request body", () => {
27+
it.effect("preserves async create invariants for arbitrary Playwright resource limits", () =>
28+
Effect.sync(() => {
29+
fc.assert(
30+
fc.property(
31+
optionalPlaywrightLimitArbitrary,
32+
optionalPlaywrightLimitArbitrary,
33+
(playwrightCpuLimit, playwrightRamLimit) => {
34+
const draft: CreateProjectRequestDraft = {
35+
...projectDraft,
36+
playwrightCpuLimit,
37+
playwrightRamLimit
38+
}
39+
const body = createProjectAcceptedBody(draft)
40+
41+
expect(body.async).toBe(true)
42+
expect(body.openSsh).toBe(false)
43+
expect(body.useManagedAuthorizedKeys).toBe(true)
44+
expect(hasOwnField(body, "playwrightCpuLimit")).toBe(playwrightCpuLimit !== undefined)
45+
expect(hasOwnField(body, "playwrightRamLimit")).toBe(playwrightRamLimit !== undefined)
46+
if (playwrightCpuLimit !== undefined) {
47+
expect(body.playwrightCpuLimit).toBe(playwrightCpuLimit)
48+
}
49+
if (playwrightRamLimit !== undefined) {
50+
expect(body.playwrightRamLimit).toBe(playwrightRamLimit)
51+
}
52+
}
53+
),
54+
{ numRuns: 50 }
55+
)
56+
}))
57+
2358
it.effect("serializes async create requests with Playwright resource limits", () =>
2459
Effect.sync(() => {
2560
expect(createProjectAcceptedBody(projectDraft)).toEqual({

packages/app/tests/docker-git/api-terminal.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ describe("api terminal helpers", () => {
7979
const result = yield* _(Effect.either(deleteTerminalSessionByPath("/terminal-sessions/session-1")))
8080

8181
expect(result._tag).toBe("Left")
82+
if (result._tag === "Left") {
83+
expect(result.left).toBe("Invalid terminal close path: /terminal-sessions/session-1")
84+
}
8285
expect(capturedDeleteRequests).toEqual([])
8386
}))
8487
})

0 commit comments

Comments
 (0)