Skip to content

Commit 68a1d56

Browse files
committed
fix(api): align OpenAPI contract with handlers
1 parent 38cd8ea commit 68a1d56

14 files changed

Lines changed: 2660 additions & 624 deletions

packages/api/openapi.json

Lines changed: 1684 additions & 411 deletions
Large diffs are not rendered by default.

packages/api/src/api/openapi.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -465,9 +465,16 @@ const QueryLinesSchema = Schema.Struct({
465465
lines: Schema.optional(Schema.String)
466466
})
467467

468+
const ApiErrorEnvelopeSchema = Schema.Struct({
469+
command: Schema.optional(Schema.String),
470+
details: Schema.optional(Schema.Unknown),
471+
message: Schema.String,
472+
provider: Schema.optional(Schema.String),
473+
type: Schema.String
474+
})
475+
468476
const ApiErrorResponseSchema = Schema.Struct({
469-
error: Schema.String,
470-
message: Schema.String
477+
error: ApiErrorEnvelopeSchema
471478
})
472479

473480
const endpoint = {
@@ -486,7 +493,7 @@ const ProjectsGroup = HttpApiGroup.make("projects")
486493
.add(
487494
endpoint.post("createProject", "/projects")
488495
.setPayload(CreateProjectRequestSchema)
489-
.addSuccess(ProjectResponseSchema)
496+
.addSuccess(ProjectResponseSchema, { status: 201 })
490497
.addSuccess(CreateProjectAcceptedResponseSchema, { status: 202 })
491498
)
492499
.add(
@@ -518,7 +525,7 @@ const ProjectPortsGroup = HttpApiGroup.make("projectPorts")
518525
.add(
519526
endpoint.post("createProjectPort")`/projects/${ProjectIdParam}/ports`
520527
.setPayload(ProjectPortForwardRequestSchema)
521-
.addSuccess(ProjectPortForwardResponseSchema)
528+
.addSuccess(ProjectPortForwardResponseSchema, { status: 201 })
522529
)
523530
.add(
524531
endpoint.del("deleteProjectPort")`/projects/${ProjectIdParam}/ports/${TargetPortParam}`
@@ -540,15 +547,15 @@ const ProjectDatabasesGroup = HttpApiGroup.make("projectDatabases")
540547
.add(
541548
endpoint.post("saveDatabaseProfile")`/projects/${ProjectIdParam}/databases/profiles`
542549
.setPayload(ProjectDatabaseProfileRequestSchema)
543-
.addSuccess(ProjectDatabaseProfileResponseSchema)
550+
.addSuccess(ProjectDatabaseProfileResponseSchema, { status: 201 })
544551
)
545552
.add(
546553
endpoint.del("deleteDatabaseProfile")`/projects/${ProjectIdParam}/databases/profiles/${ProfileIdParam}`
547554
.addSuccess(EmptyResponseSchema)
548555
)
549556
.add(
550557
endpoint.post("exposeDatabaseProfile")`/projects/${ProjectIdParam}/databases/profiles/${ProfileIdParam}/expose`
551-
.addSuccess(ProjectDatabaseForwardResponseSchema)
558+
.addSuccess(ProjectDatabaseForwardResponseSchema, { status: 201 })
552559
)
553560
.add(
554561
endpoint.del("deleteDatabaseForward")`/projects/${ProjectIdParam}/databases/profiles/${ProfileIdParam}/expose`
@@ -624,7 +631,7 @@ const AuthGroup = HttpApiGroup.make("auth")
624631
.add(
625632
endpoint.post("authTerminalSession", "/auth/terminal-sessions")
626633
.setPayload(AuthTerminalSessionRequestSchema)
627-
.addSuccess(AuthTerminalSessionResponseSchema)
634+
.addSuccess(AuthTerminalSessionResponseSchema, { status: 201 })
628635
)
629636
.add(
630637
endpoint.post("codexImport", "/auth/codex/import")
@@ -653,7 +660,7 @@ const ProjectAuthGroup = HttpApiGroup.make("projectAuth")
653660
const TerminalGroup = HttpApiGroup.make("terminal")
654661
.add(
655662
endpoint.post("createTerminalByKey")`/projects/by-key/${ProjectKeyParam}/terminal-sessions`
656-
.addSuccess(TerminalSessionResponseSchema)
663+
.addSuccess(TerminalSessionResponseSchema, { status: 201 })
657664
)
658665
.add(
659666
endpoint.post("startTerminalByKey")`/projects/by-key/${ProjectKeyParam}/terminal-sessions/start`

packages/api/tests/openapi.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,45 @@ describe("openapi contract", () => {
2222
expect(paths["/projects/{projectId}/auth"]).toBeUndefined()
2323
expect(Object.keys(paths)).toHaveLength(54)
2424
}))
25+
26+
it.effect("documents real HTTP success status codes for create and async endpoints", () =>
27+
Effect.sync(() => {
28+
const spec = buildDockerGitOpenApi()
29+
const paths = spec.paths ?? {}
30+
31+
const postResponseStatuses = (path: string): ReadonlyArray<string> =>
32+
Object.keys(paths[path]?.post?.responses ?? {})
33+
34+
expect(postResponseStatuses("/projects")).toEqual(expect.arrayContaining(["201", "202", "400"]))
35+
expect(postResponseStatuses("/projects/{projectId}/ports")).toEqual(expect.arrayContaining(["201", "400"]))
36+
expect(postResponseStatuses("/projects/{projectId}/databases/profiles")).toEqual(
37+
expect.arrayContaining(["201", "400"])
38+
)
39+
expect(postResponseStatuses("/projects/{projectId}/databases/profiles/{profileId}/expose")).toEqual(
40+
expect.arrayContaining(["201", "400"])
41+
)
42+
expect(postResponseStatuses("/auth/terminal-sessions")).toEqual(expect.arrayContaining(["201", "400"]))
43+
expect(postResponseStatuses("/projects/by-key/{projectKey}/terminal-sessions")).toEqual(
44+
expect.arrayContaining(["201", "400"])
45+
)
46+
expect(postResponseStatuses("/projects/by-key/{projectKey}/terminal-sessions/start")).toEqual(
47+
expect.arrayContaining(["202", "400"])
48+
)
49+
}))
50+
51+
it.effect("documents the nested API error envelope used by HTTP handlers", () =>
52+
Effect.sync(() => {
53+
const spec = buildDockerGitOpenApi()
54+
const serializedBadRequestSchema = JSON.stringify(
55+
spec.paths?.["/projects"]?.post?.responses?.["400"] ?? {}
56+
)
57+
58+
expect(serializedBadRequestSchema).toContain("\"required\":[\"error\"]")
59+
expect(serializedBadRequestSchema).toContain("\"error\":{\"type\":\"object\"")
60+
expect(serializedBadRequestSchema).toContain("\"type\":{\"type\":\"string\"")
61+
expect(serializedBadRequestSchema).toContain("\"message\":{\"type\":\"string\"")
62+
expect(serializedBadRequestSchema).toContain("\"provider\":{\"type\":\"string\"")
63+
expect(serializedBadRequestSchema).toContain("\"command\":{\"type\":\"string\"")
64+
expect(serializedBadRequestSchema).not.toContain("\"required\":[\"error\",\"message\"]")
65+
}))
2566
})

packages/app/src/web/api-auth-schema.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ export const GithubStatusResponseSchema = Schema.Struct({
2323
status: GithubAuthStatusSchema
2424
})
2525

26+
/**
27+
* Boundary schema for the controller Codex authentication status.
28+
*
29+
* @pure true - describes immutable response data only.
30+
* @effect none
31+
* @invariant account may be null, while authPath, label, message, and present are always typed.
32+
* @precondition input is an unknown JSON value received from the API boundary.
33+
* @postcondition successful decoding yields the UI-safe Codex auth status shape.
34+
* @complexity O(1) schema construction; O(n) decode where n is the response size.
35+
* @throws Never.
36+
*/
2637
export const CodexAuthStatusSchema = Schema.Struct({
2738
account: NullableString,
2839
authPath: Schema.String,
@@ -31,6 +42,17 @@ export const CodexAuthStatusSchema = Schema.Struct({
3142
present: Schema.Boolean
3243
})
3344

45+
/**
46+
* Boundary schema for the Codex status API response envelope.
47+
*
48+
* @pure true - describes immutable response data only.
49+
* @effect none
50+
* @invariant status is always present; ok is an optional compatibility flag.
51+
* @precondition input is an unknown JSON value received from the API boundary.
52+
* @postcondition successful decoding yields a response with a validated CodexAuthStatusSchema status.
53+
* @complexity O(1) schema construction; O(n) decode where n is the response size.
54+
* @throws Never.
55+
*/
3456
export const CodexStatusResponseSchema = Schema.Struct({
3557
ok: Schema.optional(Schema.Boolean),
3658
status: CodexAuthStatusSchema

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

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,37 @@
11
import type { Effect } from "effect"
22

3+
import {
4+
baseCreateProjectBody,
5+
type CreateProjectRequestDraft,
6+
optionalProjectResourceFields
7+
} from "./api-project-create-body.js"
38
import { CreateProjectAcceptedResponseSchema } from "./api-schema.js"
4-
import type { CreateProjectAcceptedResponse, CreateProjectDraft } from "./api-schema.js"
9+
import type { CreateProjectAcceptedResponse } from "./api-schema.js"
510
import { openApiJsonSchema } from "./openapi-client.js"
611

7-
const createProjectAcceptedBody = (draft: CreateProjectDraft) => ({
12+
/**
13+
* Builds the async POST /projects request body.
14+
*
15+
* @param draft - Validated project creation draft plus optional resource limits.
16+
* @returns Request body for an accepted asynchronous create request.
17+
*
18+
* @pure true - deterministic serialization of immutable input.
19+
* @effect none
20+
* @invariant async create uses the same common fields and optional resource fields as sync create.
21+
* @precondition draft fields were validated by the UI create flow.
22+
* @postcondition output includes async = true and preserves defined Playwright resource limits.
23+
* @complexity O(1).
24+
* @throws Never.
25+
*/
26+
export const createProjectAcceptedBody = (draft: CreateProjectRequestDraft) => ({
27+
...baseCreateProjectBody(draft),
828
async: true,
9-
cpuLimit: draft.cpuLimit,
10-
enableMcpPlaywright: draft.enableMcpPlaywright,
11-
force: draft.force,
12-
forceEnv: draft.forceEnv,
13-
gpu: draft.gpu,
14-
openSsh: false,
15-
outDir: draft.outDir,
16-
ramLimit: draft.ramLimit,
17-
repoRef: draft.repoRef,
18-
repoUrl: draft.repoUrl,
19-
up: draft.up,
20-
useManagedAuthorizedKeys: true
29+
...optionalProjectResourceFields(draft)
2130
})
2231

23-
export const startCreateProject = (draft: CreateProjectDraft): Effect.Effect<CreateProjectAcceptedResponse, string> =>
32+
export const startCreateProject = (
33+
draft: CreateProjectRequestDraft
34+
): Effect.Effect<CreateProjectAcceptedResponse, string> =>
2435
openApiJsonSchema(CreateProjectAcceptedResponseSchema, (client) =>
2536
client.POST("/projects", {
2637
body: createProjectAcceptedBody(draft)

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

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,16 @@
11
import { Effect } from "effect"
22

3-
import type { ApplyProjectRequest, ProjectResourceLimitRequest } from "../shared/project-resource-request.js"
4-
import type { CreateProjectDraft } from "./api-schema.js"
3+
import type { ApplyProjectRequest } from "../shared/project-resource-request.js"
4+
import {
5+
baseCreateProjectBody,
6+
type CreateProjectRequestDraft,
7+
optionalProjectResourceFields
8+
} from "./api-project-create-body.js"
59
import { OutputResponseSchema, ProjectResponseSchema } from "./api-schema.js"
610
import { openApiJsonSchema } from "./openapi-client.js"
711

812
export type { ApplyProjectRequest, ProjectResourceLimitRequest } from "../shared/project-resource-request.js"
9-
10-
type CreateProjectRequestDraft = CreateProjectDraft & ProjectResourceLimitRequest
11-
12-
const optionalProjectResourceFields = (request: ProjectResourceLimitRequest) => ({
13-
...(request.playwrightCpuLimit !== undefined && { playwrightCpuLimit: request.playwrightCpuLimit }),
14-
...(request.playwrightRamLimit !== undefined && { playwrightRamLimit: request.playwrightRamLimit })
15-
})
13+
export type { CreateProjectRequestDraft } from "./api-project-create-body.js"
1614

1715
const applyProjectBody = (request: ApplyProjectRequest | undefined) => ({
1816
...(request?.cpuLimit !== undefined && { cpuLimit: request.cpuLimit }),
@@ -22,18 +20,7 @@ const applyProjectBody = (request: ApplyProjectRequest | undefined) => ({
2220
})
2321

2422
const createProjectBody = (draft: CreateProjectRequestDraft) => ({
25-
cpuLimit: draft.cpuLimit,
26-
enableMcpPlaywright: draft.enableMcpPlaywright,
27-
force: draft.force,
28-
forceEnv: draft.forceEnv,
29-
gpu: draft.gpu,
30-
openSsh: false,
31-
outDir: draft.outDir,
32-
ramLimit: draft.ramLimit,
33-
repoRef: draft.repoRef,
34-
repoUrl: draft.repoUrl,
35-
up: draft.up,
36-
useManagedAuthorizedKeys: true,
23+
...baseCreateProjectBody(draft),
3724
...optionalProjectResourceFields(draft)
3825
})
3926

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { ProjectResourceLimitRequest } from "../shared/project-resource-request.js"
2+
import type { CreateProjectDraft } from "./api-schema.js"
3+
4+
/**
5+
* Draft accepted by POST /projects helpers.
6+
*
7+
* @pure true - type-only boundary contract.
8+
* @effect none
9+
* @invariant includes the base project draft plus optional resource limit fields shared by sync and async create flows.
10+
* @precondition callers already validated UI input into CreateProjectDraft fields.
11+
* @postcondition request builders can serialize the same resource fields for both create variants.
12+
* @complexity O(1).
13+
* @throws Never.
14+
*/
15+
export type CreateProjectRequestDraft = CreateProjectDraft & ProjectResourceLimitRequest
16+
17+
/**
18+
* Serializes optional Playwright resource limits for project creation requests.
19+
*
20+
* @param request - Shared resource limit fields from the validated create draft.
21+
* @returns Object containing only defined Playwright limit fields.
22+
*
23+
* @pure true - deterministic projection from immutable input.
24+
* @effect none
25+
* @invariant undefined optional fields are omitted from the request body.
26+
* @precondition request is a validated web create/apply resource limit request.
27+
* @postcondition output is safe to spread into a JSON request body.
28+
* @complexity O(1).
29+
* @throws Never.
30+
*/
31+
export const optionalProjectResourceFields = (request: ProjectResourceLimitRequest) => ({
32+
...(request.playwrightCpuLimit !== undefined && { playwrightCpuLimit: request.playwrightCpuLimit }),
33+
...(request.playwrightRamLimit !== undefined && { playwrightRamLimit: request.playwrightRamLimit })
34+
})
35+
36+
/**
37+
* Builds the common POST /projects request body used by sync and async flows.
38+
*
39+
* @param draft - Validated project creation draft.
40+
* @returns Shared request body fields without the flow-specific async flag.
41+
*
42+
* @pure true - deterministic serialization of create draft fields.
43+
* @effect none
44+
* @invariant sync and async create requests share one definition of common fields.
45+
* @precondition draft fields were validated by the UI create flow.
46+
* @postcondition output preserves all non-optional project creation fields.
47+
* @complexity O(1).
48+
* @throws Never.
49+
*/
50+
export const baseCreateProjectBody = (draft: CreateProjectDraft) => ({
51+
cpuLimit: draft.cpuLimit,
52+
enableMcpPlaywright: draft.enableMcpPlaywright,
53+
force: draft.force,
54+
forceEnv: draft.forceEnv,
55+
gpu: draft.gpu,
56+
openSsh: false,
57+
outDir: draft.outDir,
58+
ramLimit: draft.ramLimit,
59+
repoRef: draft.repoRef,
60+
repoUrl: draft.repoUrl,
61+
up: draft.up,
62+
useManagedAuthorizedKeys: true
63+
})

0 commit comments

Comments
 (0)