Skip to content

Commit a0f08e5

Browse files
committed
fix(api): align OpenAPI response contract
1 parent a09a3ab commit a0f08e5

6 files changed

Lines changed: 22672 additions & 6081 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"web:dev": "bun run --cwd packages/app dev:web",
4545
"web:build": "bun run --cwd packages/app build:web",
4646
"web:generate-api": "bun run --cwd packages/openapi generate",
47+
"openapi:lint-contract": "bun run --cwd packages/api lint:openapi-contract",
4748
"web:preview": "bun run --cwd packages/app preview:web",
4849
"web:serve": "bun run --cwd packages/app serve:web",
4950
"lint": "bun run --filter @prover-coder-ai/docker-git-terminal lint && bun run --filter @prover-coder-ai/docker-git lint && bun run --filter @effect-template/lib lint",

packages/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"typecheck": "tsc --noEmit -p tsconfig.json",
1717
"lint": "eslint .",
1818
"lint:effect": "eslint --config eslint.effect-ts-check.config.mjs .",
19+
"lint:openapi-contract": "vitest run tests/openapi.test.ts",
1920
"pretest": "bun run --cwd ../terminal build && bun run --cwd ../container build && bun run --cwd ../lib build",
2021
"test": "vitest run"
2122
},

packages/api/src/api/openapi.ts

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import * as Schema from "effect/Schema"
77

88
import {
99
ActiveProjectTerminalSessionRequestSchema,
10+
AgentSessionSchema,
1011
ApplyAllRequestSchema,
1112
ApplyProjectRequestSchema,
1213
AuthMenuRequestSchema,
@@ -47,7 +48,9 @@ const ScopeIdParam = HttpApiSchema.param("scopeId", Schema.String)
4748
const SkillNameParam = HttpApiSchema.param("name", Schema.String)
4849
const PidParam = HttpApiSchema.param("pid", Schema.NumberFromString)
4950

50-
export const EmptyResponseSchema = Schema.Void
51+
export const OkResponseSchema = Schema.Struct({
52+
ok: Schema.Literal(true)
53+
})
5154

5255
export const HealthResponseSchema = Schema.Struct({
5356
cwd: Schema.String,
@@ -301,8 +304,7 @@ export const ProjectAuthSnapshotSchema = Schema.Struct({
301304
grokAuthEntries: Schema.optionalWith(Schema.Number, { default: () => 0 }),
302305
grokAuthPath: Schema.optionalWith(Schema.String, { default: () => "" }),
303306
projectDir: Schema.String,
304-
projectName: Schema.String,
305-
totalEntries: Schema.Number
307+
projectName: Schema.String
306308
})
307309

308310
export const ProjectAuthSnapshotResponseSchema = Schema.Struct({
@@ -338,6 +340,11 @@ export const ProjectTerminalSessionResponseSchema = Schema.Struct({
338340
session: TerminalSessionSchema
339341
})
340342

343+
export const ActiveProjectTerminalSessionResponseSchema = Schema.Struct({
344+
ok: OptionalOkSchema,
345+
session: TerminalSessionSchema
346+
})
347+
341348
export const TerminalSessionLookupResponseSchema = Schema.Struct({
342349
projectDisplayName: Schema.String,
343350
projectKey: Schema.String,
@@ -442,11 +449,13 @@ export const ContainerTaskSchema = Schema.Struct({
442449
})
443450

444451
export const ContainerTaskSnapshotSchema = Schema.Struct({
452+
agents: Schema.Array(AgentSessionSchema),
445453
containerName: Schema.String,
446454
generatedAt: Schema.String,
447455
projectId: Schema.String,
448456
sshConnections: Schema.Number,
449-
tasks: Schema.Array(ContainerTaskSchema)
457+
tasks: Schema.Array(ContainerTaskSchema),
458+
terminalSessions: Schema.Array(TerminalSessionSchema)
450459
})
451460

452461
export const ContainerTaskSnapshotResponseSchema = Schema.Struct({
@@ -499,12 +508,12 @@ const ProjectsGroup = HttpApiGroup.make("projects")
499508
.add(
500509
endpoint.post("applyAllProjects", "/projects/apply-all")
501510
.setPayload(ApplyAllRequestSchema)
502-
.addSuccess(EmptyResponseSchema)
511+
.addSuccess(OkResponseSchema)
503512
)
504-
.add(endpoint.post("downAllProjects", "/projects/down-all").addSuccess(EmptyResponseSchema))
513+
.add(endpoint.post("downAllProjects", "/projects/down-all").addSuccess(OkResponseSchema))
505514
.add(endpoint.get("getProject")`/projects/${ProjectIdParam}`.addSuccess(ProjectResponseSchema))
506-
.add(endpoint.del("deleteProject")`/projects/${ProjectIdParam}`.addSuccess(EmptyResponseSchema))
507-
.add(endpoint.post("downProject")`/projects/${ProjectIdParam}/down`.addSuccess(EmptyResponseSchema))
515+
.add(endpoint.del("deleteProject")`/projects/${ProjectIdParam}`.addSuccess(OkResponseSchema))
516+
.add(endpoint.post("downProject")`/projects/${ProjectIdParam}/down`.addSuccess(OkResponseSchema))
508517
.add(
509518
endpoint.post("applyProject")`/projects/${ProjectIdParam}/apply`
510519
.setPayload(ApplyProjectRequestSchema)
@@ -529,7 +538,7 @@ const ProjectPortsGroup = HttpApiGroup.make("projectPorts")
529538
)
530539
.add(
531540
endpoint.del("deleteProjectPort")`/projects/${ProjectIdParam}/ports/${TargetPortParam}`
532-
.addSuccess(EmptyResponseSchema)
541+
.addSuccess(OkResponseSchema)
533542
)
534543

535544
const ProjectBrowserGroup = HttpApiGroup.make("projectBrowser")
@@ -551,15 +560,15 @@ const ProjectDatabasesGroup = HttpApiGroup.make("projectDatabases")
551560
)
552561
.add(
553562
endpoint.del("deleteDatabaseProfile")`/projects/${ProjectIdParam}/databases/profiles/${ProfileIdParam}`
554-
.addSuccess(EmptyResponseSchema)
563+
.addSuccess(OkResponseSchema)
555564
)
556565
.add(
557566
endpoint.post("exposeDatabaseProfile")`/projects/${ProjectIdParam}/databases/profiles/${ProfileIdParam}/expose`
558567
.addSuccess(ProjectDatabaseForwardResponseSchema, { status: 201 })
559568
)
560569
.add(
561570
endpoint.del("deleteDatabaseForward")`/projects/${ProjectIdParam}/databases/profiles/${ProfileIdParam}/expose`
562-
.addSuccess(EmptyResponseSchema)
571+
.addSuccess(OkResponseSchema)
563572
)
564573
.add(
565574
endpoint.get("listDatabaseForwards")`/projects/${ProjectIdParam}/databases/forwards`
@@ -677,16 +686,16 @@ const TerminalGroup = HttpApiGroup.make("terminal")
677686
)
678687
.add(
679688
endpoint.del("deleteTerminalByKey")`/projects/by-key/${ProjectKeyParam}/terminal-sessions/${SessionIdParam}`
680-
.addSuccess(EmptyResponseSchema)
689+
.addSuccess(OkResponseSchema)
681690
)
682691
.add(
683692
endpoint.put("setActiveTerminalByKey")`/projects/by-key/${ProjectKeyParam}/terminal-sessions/active`
684693
.setPayload(ActiveProjectTerminalSessionRequestSchema)
685-
.addSuccess(ProjectTerminalSessionResponseSchema)
694+
.addSuccess(ActiveProjectTerminalSessionResponseSchema)
686695
)
687696
.add(endpoint.get("lookupTerminal")`/terminal-sessions/${SessionIdParam}`.addSuccess(TerminalSessionLookupResponseSchema))
688697
.add(
689-
endpoint.del("deleteAuthTerminal")`/auth/terminal-sessions/${SessionIdParam}`.addSuccess(EmptyResponseSchema)
698+
endpoint.del("deleteAuthTerminal")`/auth/terminal-sessions/${SessionIdParam}`.addSuccess(OkResponseSchema)
690699
)
691700

692701
const PromptsGroup = HttpApiGroup.make("prompts")
@@ -719,7 +728,7 @@ const TasksGroup = HttpApiGroup.make("tasks")
719728
.setUrlParams(QueryIncludeDefaultSchema)
720729
.addSuccess(ContainerTaskSnapshotResponseSchema)
721730
)
722-
.add(endpoint.post("stopTask")`/projects/${ProjectIdParam}/tasks/${PidParam}/stop`.addSuccess(EmptyResponseSchema))
731+
.add(endpoint.post("stopTask")`/projects/${ProjectIdParam}/tasks/${PidParam}/stop`.addSuccess(OkResponseSchema))
723732
.add(
724733
endpoint.get("taskLogs")`/projects/${ProjectIdParam}/tasks/${PidParam}/logs`
725734
.setUrlParams(QueryLinesSchema)
@@ -740,6 +749,10 @@ export const DockerGitApi = HttpApi.make("docker-git")
740749
.annotate(OpenApi.Version, "1.0.0")
741750
.annotate(OpenApi.Description, "Effect contract for docker-git JSON REST endpoints.")
742751
.addError(ApiErrorResponseSchema, { status: 400 })
752+
.addError(ApiErrorResponseSchema, { status: 401 })
753+
.addError(ApiErrorResponseSchema, { status: 404 })
754+
.addError(ApiErrorResponseSchema, { status: 409 })
755+
.addError(ApiErrorResponseSchema, { status: 500 })
743756
.add(CoreGroup)
744757
.add(ProjectsGroup)
745758
.add(ProjectPortsGroup)

packages/api/tests/openapi.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import { Effect } from "effect"
33

44
import { buildDockerGitOpenApi } from "../src/api/openapi.js"
55

6+
const documentedMethods = ["delete", "get", "post", "put"] as const
7+
const commonErrorStatuses = ["400", "401", "404", "409", "500"]
8+
69
describe("openapi contract", () => {
710
it.effect("documents generated REST paths from the Effect HttpApi contract", () =>
811
Effect.sync(() => {
@@ -63,4 +66,87 @@ describe("openapi contract", () => {
6366
expect(serializedBadRequestSchema).toContain("\"command\":{\"type\":\"string\"")
6467
expect(serializedBadRequestSchema).not.toContain("\"required\":[\"error\",\"message\"]")
6568
}))
69+
70+
it.effect("documents common API error statuses for every JSON REST operation", () =>
71+
Effect.sync(() => {
72+
const spec = buildDockerGitOpenApi()
73+
const paths = spec.paths ?? {}
74+
75+
for (const [path, item] of Object.entries(paths)) {
76+
for (const method of documentedMethods) {
77+
const responses = item[method]?.responses
78+
if (responses === undefined) {
79+
continue
80+
}
81+
82+
expect(Object.keys(responses), `${method.toUpperCase()} ${path}`).toEqual(
83+
expect.arrayContaining(commonErrorStatuses)
84+
)
85+
}
86+
}
87+
}))
88+
89+
it.effect("documents ok-only HTTP handlers as 200 JSON responses", () =>
90+
Effect.sync(() => {
91+
const spec = buildDockerGitOpenApi()
92+
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
105+
106+
for (const operation of okOnlyOperations) {
107+
const responses = paths[operation.path]?.[operation.method]?.responses ?? {}
108+
const serializedSuccessSchema = JSON.stringify(responses["200"] ?? {})
109+
110+
expect(responses["200"], `${operation.method.toUpperCase()} ${operation.path}`).toBeDefined()
111+
expect(responses["204"], `${operation.method.toUpperCase()} ${operation.path}`).toBeUndefined()
112+
expect(serializedSuccessSchema).toContain("\"required\":[\"ok\"]")
113+
expect(serializedSuccessSchema).toContain("\"ok\":{\"type\":\"boolean\",\"enum\":[true]}")
114+
}
115+
}))
116+
117+
it.effect("documents project auth snapshots without nonexistent totalEntries", () =>
118+
Effect.sync(() => {
119+
const spec = buildDockerGitOpenApi()
120+
const serializedProjectAuthSchema = JSON.stringify(
121+
spec.paths?.["/projects/{projectId}/auth/menu"]?.get?.responses?.["200"] ?? {}
122+
)
123+
124+
expect(serializedProjectAuthSchema).toContain("\"projectName\":{\"type\":\"string\"")
125+
expect(serializedProjectAuthSchema).not.toContain("\"totalEntries\"")
126+
}))
127+
128+
it.effect("documents active terminal response ok envelope", () =>
129+
Effect.sync(() => {
130+
const spec = buildDockerGitOpenApi()
131+
const serializedActiveTerminalSchema = JSON.stringify(
132+
spec.paths?.["/projects/by-key/{projectKey}/terminal-sessions/active"]?.put?.responses?.["200"] ?? {}
133+
)
134+
135+
expect(serializedActiveTerminalSchema).toContain("\"required\":[\"session\"]")
136+
expect(serializedActiveTerminalSchema).toContain("\"ok\":{\"type\":\"boolean\"")
137+
expect(serializedActiveTerminalSchema).toContain("\"session\":{\"type\":\"object\"")
138+
}))
139+
140+
it.effect("documents task snapshot terminal sessions and agents", () =>
141+
Effect.sync(() => {
142+
const spec = buildDockerGitOpenApi()
143+
const serializedTaskSchema = JSON.stringify(
144+
spec.paths?.["/projects/{projectId}/tasks"]?.get?.responses?.["200"] ?? {}
145+
)
146+
147+
expect(serializedTaskSchema).toContain("\"required\":[\"agents\"")
148+
expect(serializedTaskSchema).toContain("\"terminalSessions\"")
149+
expect(serializedTaskSchema).toContain("\"provider\":{\"type\":\"string\"")
150+
expect(serializedTaskSchema).toContain("\"sshCommand\":{\"type\":\"string\"")
151+
}))
66152
})

0 commit comments

Comments
 (0)