From b28029359816e5cbe094f61a8cdb09debfc762f2 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 19 Jun 2026 08:21:26 +0000 Subject: [PATCH 1/8] refactor(openapi): use openapi-effect client --- bun.lock | 49 +++- packages/api/src/api/openapi.ts | 4 +- packages/api/src/http.ts | 2 +- packages/app/src/web/api-create-project.ts | 4 +- packages/app/src/web/api-database.ts | 20 +- packages/app/src/web/api-http.ts | 16 ++ packages/app/src/web/api-project-core.ts | 18 +- packages/app/src/web/api-prompts.ts | 49 ++-- packages/app/src/web/api-share.ts | 27 ++- packages/app/src/web/api-skills.ts | 36 +-- packages/app/src/web/api-tasks.ts | 46 ++-- packages/app/src/web/api-terminal.ts | 44 ++-- packages/app/src/web/api.ts | 116 +++++---- packages/app/src/web/openapi-client.ts | 46 ---- .../app/tests/docker-git/api-terminal.test.ts | 8 +- .../docker-git/openapi-effect-client.test.ts | 124 ++++++++++ packages/app/tsconfig.json | 1 + packages/openapi/package.json | 3 +- packages/openapi/src/client.ts | 172 ++++++++------ .../openapi/src/types/openapi-effect.d.ts | 223 ++++++++++++++++++ packages/openapi/tsconfig.json | 3 + 21 files changed, 735 insertions(+), 276 deletions(-) delete mode 100644 packages/app/src/web/openapi-client.ts create mode 100644 packages/app/tests/docker-git/openapi-effect-client.test.ts create mode 100644 packages/openapi/src/types/openapi-effect.d.ts diff --git a/bun.lock b/bun.lock index edacee9f..c42148d6 100644 --- a/bun.lock +++ b/bun.lock @@ -237,8 +237,9 @@ "version": "0.1.0", "dependencies": { "@effect/schema": "^0.75.5", + "@prover-coder-ai/openapi-effect": "^1.0.22", "effect": "^3.21.3", - "openapi-fetch": "^0.17.0", + "openapi-typescript-helpers": "^0.1.0", }, "devDependencies": { "openapi-typescript": "^7.13.0", @@ -676,6 +677,8 @@ "@prover-coder-ai/eslint-plugin-suggest-members": ["@prover-coder-ai/eslint-plugin-suggest-members@0.0.26", "", { "dependencies": { "@effect/platform": "^0.96.0", "@effect/platform-node": "^0.106.0", "@effect/schema": "^0.75.5", "@typescript-eslint/utils": "8.57.2", "effect": "^3.21.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <7.0.0" } }, "sha512-RWl1jYZTMK1p0L6GA7VXvTrtiNkbQyjkgk3mvz0Vv7ImTrctDOLFfNIRoJmhU+e5irj1u5uK2p9QoZtRzi4ILQ=="], + "@prover-coder-ai/openapi-effect": ["@prover-coder-ai/openapi-effect@1.0.22", "", { "dependencies": { "@effect/platform": "^0.94.5", "@effect/platform-node": "^0.104.1", "@effect/schema": "^0.75.5", "effect": "^3.19.18" } }, "sha512-4M5TTZAnr9SrlksjvV356GgdK2Bg1iEAuP6iYqatBq2nWvHgcLFQvS8SQtE0GquEOR73C/1FbOAzZ0Nrs9aFUQ=="], + "@redocly/ajv": ["@redocly/ajv@8.11.2", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js-replace": "^1.0.1" } }, "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg=="], "@redocly/config": ["@redocly/config@0.22.0", "", {}, "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ=="], @@ -1556,8 +1559,6 @@ "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - "openapi-fetch": ["openapi-fetch@0.17.0", "", { "dependencies": { "openapi-typescript-helpers": "^0.1.0" } }, "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig=="], - "openapi-typescript": ["openapi-typescript@7.13.0", "", { "dependencies": { "@redocly/openapi-core": "^1.34.6", "ansi-colors": "^4.1.3", "change-case": "^5.4.4", "parse-json": "^8.3.0", "supports-color": "^10.2.2", "yargs-parser": "^21.1.1" }, "peerDependencies": { "typescript": "^5.x" }, "bin": { "openapi-typescript": "bin/cli.js" } }, "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ=="], "openapi-typescript-helpers": ["openapi-typescript-helpers@0.1.0", "", {}, "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw=="], @@ -2050,6 +2051,10 @@ "@prover-coder-ai/eslint-plugin-suggest-members/effect": ["effect@3.21.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-rXd2FGDM8KdjSIrc+mqEELo7ScW7xTVxEf1iInmPSpIde9/nyGuFM710cjTo7/EreGXiUX2MOonPpprbz2XHCg=="], + "@prover-coder-ai/openapi-effect/@effect/platform": ["@effect/platform@0.94.5", "", { "dependencies": { "find-my-way-ts": "0.1.6", "msgpackr": "1.11.5", "multipasta": "0.2.7" }, "peerDependencies": { "effect": "3.21.0" } }, "sha512-z05APUiDDPbodhTkH/RJqOLoCU11bU2IZLfcwLFrld03+ob1VeqRnELQlmueLIYm6NZifHAtjl32V+GRt34y4A=="], + + "@prover-coder-ai/openapi-effect/@effect/platform-node": ["@effect/platform-node@0.104.1", "", { "dependencies": { "@effect/platform-node-shared": "0.57.1", "mime": "3.0.0", "undici": "7.16.0", "ws": "8.18.3" }, "peerDependencies": { "@effect/cluster": "0.58.0", "@effect/platform": "0.94.5", "@effect/rpc": "0.75.0", "@effect/sql": "0.51.0", "effect": "3.21.0" } }, "sha512-jT1a/z98niK6fnEU8pWHPPCdJMVDRCIdB65lolcOjse5rsTwVbczMjvKkhVQpF63mNWoOnol7OTRNkw5L54llg=="], + "@redocly/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@redocly/openapi-core/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], @@ -2326,6 +2331,22 @@ "@prover-coder-ai/eslint-plugin-suggest-members/effect/fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + "@prover-coder-ai/openapi-effect/@effect/platform/effect": ["effect@3.21.0", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "fast-check": "3.23.2" } }, "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ=="], + + "@prover-coder-ai/openapi-effect/@effect/platform/msgpackr": ["msgpackr@1.11.5", "", { "optionalDependencies": { "msgpackr-extract": "3.0.3" } }, "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA=="], + + "@prover-coder-ai/openapi-effect/@effect/platform-node/@effect/cluster": ["@effect/cluster@0.58.0", "", { "dependencies": { "kubernetes-types": "1.30.0" }, "peerDependencies": { "@effect/platform": "0.96.0", "@effect/rpc": "0.75.0", "@effect/sql": "0.51.0", "@effect/workflow": "0.18.0", "effect": "3.21.0" } }, "sha512-0Zog7s7XdntWcTqdqWPoj6nc7hPaWIzp0k0DsFUWyCynXNPK9dAtgFrSce04NhddNqqbhtZck/lhuqJwNBrprQ=="], + + "@prover-coder-ai/openapi-effect/@effect/platform-node/@effect/platform-node-shared": ["@effect/platform-node-shared@0.57.1", "", { "dependencies": { "@parcel/watcher": "2.5.1", "multipasta": "0.2.7", "ws": "8.18.3" }, "peerDependencies": { "@effect/cluster": "0.58.0", "@effect/platform": "0.94.5", "@effect/rpc": "0.75.0", "@effect/sql": "0.51.0", "effect": "3.21.0" } }, "sha512-oX/bApMdoKsyrDiNdJxo7U9Rz1RXsjRv+ecfAPp1qGlSdGIo32wVRvJ2XCHqYj0sqaYJS0pU0/GCulRfVGuJag=="], + + "@prover-coder-ai/openapi-effect/@effect/platform-node/@effect/rpc": ["@effect/rpc@0.75.0", "", { "dependencies": { "msgpackr": "1.11.5" }, "peerDependencies": { "@effect/platform": "0.96.0", "effect": "3.21.0" } }, "sha512-VFeJ16cZUXqiIzG9UHOVKGuiBPJ7fV+0lEbJU6xi12JnnxXe/19BQPpOwiRawCUbPOR3/xIURDUgGxU+Ft0pvQ=="], + + "@prover-coder-ai/openapi-effect/@effect/platform-node/@effect/sql": ["@effect/sql@0.51.0", "", { "dependencies": { "uuid": "11.1.0" }, "peerDependencies": { "@effect/experimental": "0.60.0", "@effect/platform": "0.96.0", "effect": "3.21.0" } }, "sha512-e7hWe46QD15eMCr4kNBMVdItIVK/WLHJG+d8DLL1FjVf5Ra82k2mwUYIXplJewVbHjt3my6GSKPPd1ZrQjVd5A=="], + + "@prover-coder-ai/openapi-effect/@effect/platform-node/effect": ["effect@3.21.0", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "fast-check": "3.23.2" } }, "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ=="], + + "@prover-coder-ai/openapi-effect/@effect/platform-node/ws": ["ws@8.18.3", "", {}, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "@redocly/openapi-core/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "@ton-ai-core/vibecode-linter/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -2518,6 +2539,20 @@ "@prover-coder-ai/eslint-plugin-suggest-members/effect/fast-check/pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "@prover-coder-ai/openapi-effect/@effect/platform-node/@effect/cluster/@effect/platform": ["@effect/platform@0.96.0", "", { "dependencies": { "find-my-way-ts": "0.1.6", "msgpackr": "1.11.5", "multipasta": "0.2.7" }, "peerDependencies": { "effect": "3.21.0" } }, "sha512-U7PLhkVzg7zzrgFvyWATOzD6reL87KG/fcdOxgLWBQ/J5CCU6qdPAVG+0o6o+IxcsLoqGwxs+rFxaFzrdtDV1A=="], + + "@prover-coder-ai/openapi-effect/@effect/platform-node/@effect/cluster/@effect/workflow": ["@effect/workflow@0.18.0", "", { "peerDependencies": { "@effect/experimental": "0.60.0", "@effect/platform": "0.96.0", "@effect/rpc": "0.75.0", "effect": "3.21.0" } }, "sha512-9Zp+x9ADtR0H6CRhU6wLyPcIRjO1PXjvSpUlFlBQ8piw7ldjPmnUWEY8YQuH6eExV2dalQ4z2LMiZ5Bd7XAJbA=="], + + "@prover-coder-ai/openapi-effect/@effect/platform-node/@effect/rpc/@effect/platform": ["@effect/platform@0.96.0", "", { "dependencies": { "find-my-way-ts": "0.1.6", "msgpackr": "1.11.5", "multipasta": "0.2.7" }, "peerDependencies": { "effect": "3.21.0" } }, "sha512-U7PLhkVzg7zzrgFvyWATOzD6reL87KG/fcdOxgLWBQ/J5CCU6qdPAVG+0o6o+IxcsLoqGwxs+rFxaFzrdtDV1A=="], + + "@prover-coder-ai/openapi-effect/@effect/platform-node/@effect/rpc/msgpackr": ["msgpackr@1.11.5", "", { "optionalDependencies": { "msgpackr-extract": "3.0.3" } }, "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA=="], + + "@prover-coder-ai/openapi-effect/@effect/platform-node/@effect/sql/@effect/platform": ["@effect/platform@0.96.0", "", { "dependencies": { "find-my-way-ts": "0.1.6", "msgpackr": "1.11.5", "multipasta": "0.2.7" }, "peerDependencies": { "effect": "3.21.0" } }, "sha512-U7PLhkVzg7zzrgFvyWATOzD6reL87KG/fcdOxgLWBQ/J5CCU6qdPAVG+0o6o+IxcsLoqGwxs+rFxaFzrdtDV1A=="], + + "@prover-coder-ai/openapi-effect/@effect/platform-node/effect/fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + + "@prover-coder-ai/openapi-effect/@effect/platform/effect/fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + "@redocly/openapi-core/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@ton-ai-core/vibecode-linter/effect/fast-check/pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], @@ -2626,6 +2661,14 @@ "@prover-coder-ai/eslint-plugin-suggest-members/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="], + "@prover-coder-ai/openapi-effect/@effect/platform-node/@effect/cluster/@effect/platform/msgpackr": ["msgpackr@1.11.5", "", { "optionalDependencies": { "msgpackr-extract": "3.0.3" } }, "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA=="], + + "@prover-coder-ai/openapi-effect/@effect/platform-node/@effect/sql/@effect/platform/msgpackr": ["msgpackr@1.11.5", "", { "optionalDependencies": { "msgpackr-extract": "3.0.3" } }, "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA=="], + + "@prover-coder-ai/openapi-effect/@effect/platform-node/effect/fast-check/pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + + "@prover-coder-ai/openapi-effect/@effect/platform/effect/fast-check/pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "eslint-module-utils/@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "4.0.4" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], "eslint-module-utils/@typescript-eslint/parser/@typescript-eslint/typescript-estree/tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], diff --git a/packages/api/src/api/openapi.ts b/packages/api/src/api/openapi.ts index 4c4da351..306eae30 100644 --- a/packages/api/src/api/openapi.ts +++ b/packages/api/src/api/openapi.ts @@ -782,8 +782,8 @@ export const DockerGitApi = HttpApi.make("docker-git") // CHANGE: derive Swagger/OpenAPI from the Effect HttpApi contract. // WHY: frontend clients must be generated from one typed REST contract. // QUOTE(ТЗ): "Надо сделать REST API нормальный на базе Effect и использовать Swagger." -// REF: user-message-2026-06-18-openapi-fetch -// SOURCE: https://openapi-ts.dev/openapi-fetch/ +// REF: user-message-2026-06-19-openapi-effect +// SOURCE: https://github.com/ProverCoderAI/openapi-effect // FORMAT THEOREM: forall endpoint e in DockerGitApi, e is represented in buildDockerGitOpenApi().paths. // PURITY: CORE // EFFECT: none diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 94c4752c..618a3366 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -317,7 +317,7 @@ const textResponse = (data: string, contentType: string, status = 200) => // CHANGE: expose browser-readable Swagger UI for the Effect REST contract. // WHY: generated clients and humans must inspect the same OpenAPI document. // QUOTE(ТЗ): "использовать Swagger" -// REF: user-message-2026-06-18-openapi-fetch +// REF: user-message-2026-06-19-openapi-effect // SOURCE: n/a // FORMAT THEOREM: docsPath(d) = p -> openApiPath(d) = sibling(p, "openapi.json") // PURITY: CORE diff --git a/packages/app/src/web/api-create-project.ts b/packages/app/src/web/api-create-project.ts index ad9e74cd..d485644b 100644 --- a/packages/app/src/web/api-create-project.ts +++ b/packages/app/src/web/api-create-project.ts @@ -1,5 +1,6 @@ import type { Effect } from "effect" +import { dockerGitOpenApi } from "./api-http.js" import { type BaseCreateProjectBody, baseCreateProjectBody, @@ -9,7 +10,6 @@ import { } from "./api-project-create-body.js" import { CreateProjectAcceptedResponseSchema } from "./api-schema.js" import type { CreateProjectAcceptedResponse } from "./api-schema.js" -import { openApiJsonSchema } from "./openapi-client.js" type CreateProjectAcceptedBody = Readonly< & BaseCreateProjectBody @@ -42,7 +42,7 @@ export const createProjectAcceptedBody = (draft: CreateProjectRequestDraft): Cre export const startCreateProject = ( draft: CreateProjectRequestDraft ): Effect.Effect => - openApiJsonSchema(CreateProjectAcceptedResponseSchema, (client) => + dockerGitOpenApi.openApiJsonSchema(CreateProjectAcceptedResponseSchema, (client) => client.POST("/projects", { body: createProjectAcceptedBody(draft) })) diff --git a/packages/app/src/web/api-database.ts b/packages/app/src/web/api-database.ts index 31b19dee..2d046806 100644 --- a/packages/app/src/web/api-database.ts +++ b/packages/app/src/web/api-database.ts @@ -1,5 +1,6 @@ import { Effect } from "effect" +import { dockerGitOpenApi } from "./api-http.js" import { ProjectDatabaseForwardResponseSchema, ProjectDatabaseForwardsResponseSchema, @@ -8,7 +9,6 @@ import { ProjectDatabaseSessionResponseSchema } from "./api-schema.js" import type { ProjectDatabaseForward, ProjectDatabaseSession } from "./api-schema.js" -import { openApiJsonSchema, openApiVoid } from "./openapi-client.js" export const projectDatabaseEditorUrl = (session: ProjectDatabaseSession): string => session.editorPath @@ -16,7 +16,7 @@ export const projectDatabaseExternalUrl = (forward: ProjectDatabaseForward): str `${forward.publicHost}:${forward.hostPort}` export const loadProjectDatabaseProfiles = (projectId: string) => - openApiJsonSchema( + dockerGitOpenApi.openApiJsonSchema( ProjectDatabaseProfilesResponseSchema, (client) => client.GET("/projects/{projectId}/databases/profiles", { @@ -27,7 +27,7 @@ export const loadProjectDatabaseProfiles = (projectId: string) => ) export const loadProjectDatabaseForwards = (projectId: string) => - openApiJsonSchema( + dockerGitOpenApi.openApiJsonSchema( ProjectDatabaseForwardsResponseSchema, (client) => client.GET("/projects/{projectId}/databases/forwards", { @@ -42,7 +42,7 @@ export const saveProjectDatabaseProfile = ( connectionString: string, label: string | null ) => - openApiJsonSchema( + dockerGitOpenApi.openApiJsonSchema( ProjectDatabaseProfileResponseSchema, (client) => client.POST("/projects/{projectId}/databases/profiles", { @@ -57,7 +57,7 @@ export const deleteProjectDatabaseProfile = ( projectId: string, profileId: string ) => - openApiVoid((client) => + dockerGitOpenApi.openApiVoid((client) => client.DELETE("/projects/{projectId}/databases/profiles/{profileId}", { params: { path: { profileId, projectId } } }) @@ -67,7 +67,7 @@ export const exposeProjectDatabaseProfile = ( projectId: string, profileId: string ) => - openApiJsonSchema( + dockerGitOpenApi.openApiJsonSchema( ProjectDatabaseForwardResponseSchema, (client) => client.POST("/projects/{projectId}/databases/profiles/{profileId}/expose", { @@ -81,14 +81,14 @@ export const deleteProjectDatabaseForward = ( projectId: string, profileId: string ) => - openApiVoid((client) => + dockerGitOpenApi.openApiVoid((client) => client.DELETE("/projects/{projectId}/databases/profiles/{profileId}/expose", { params: { path: { profileId, projectId } } }) ) export const loadProjectDatabaseSession = (projectId: string) => - openApiJsonSchema( + dockerGitOpenApi.openApiJsonSchema( ProjectDatabaseSessionResponseSchema, (client) => client.GET("/projects/{projectId}/databases/session", { @@ -99,7 +99,7 @@ export const loadProjectDatabaseSession = (projectId: string) => ) export const openProjectDatabaseEditor = (projectId: string) => - openApiJsonSchema( + dockerGitOpenApi.openApiJsonSchema( ProjectDatabaseSessionResponseSchema, (client) => client.POST("/projects/{projectId}/databases/open", { @@ -110,7 +110,7 @@ export const openProjectDatabaseEditor = (projectId: string) => ) export const restartProjectDatabaseEditor = (projectId: string) => - openApiJsonSchema( + dockerGitOpenApi.openApiJsonSchema( ProjectDatabaseSessionResponseSchema, (client) => client.POST("/projects/{projectId}/databases/restart", { diff --git a/packages/app/src/web/api-http.ts b/packages/app/src/web/api-http.ts index 2d844d97..c8472bdc 100644 --- a/packages/app/src/web/api-http.ts +++ b/packages/app/src/web/api-http.ts @@ -2,6 +2,7 @@ import { FetchHttpClient, HttpBody, HttpClient } from "@effect/platform" import * as ParseResult from "@effect/schema/ParseResult" import * as Schema from "@effect/schema/Schema" import * as TreeFormatter from "@effect/schema/TreeFormatter" +import { createClient } from "@prover-coder-ai/docker-git-openapi" import { Effect, Either } from "effect" import { type JsonRequest, parseResponseBody, renderJsonPayload } from "../docker-git/api-json.js" @@ -84,6 +85,21 @@ export const resolveApiBaseUrl = (): string => { : trimTrailingSlash(configured.trim()) } +/** + * Configured docker-git OpenAPI client for the web HTTP boundary. + * + * @pure false - binds the shared OpenAPI client to the app-specific base URL resolver. + * @effect none during construction; returned client methods perform HTTP IO when their Effects run. + * @invariant transport, error rendering, and schema decoding stay owned by the openapi package. + * @precondition resolveApiBaseUrl returns a valid docker-git API base URL for the current runtime. + * @postcondition app modules depend on one configured OpenAPI client instance. + * @complexity O(1)/O(1) for construction, excluding request execution. + * @throws Never. + */ +export const dockerGitOpenApi = createClient({ + resolveBaseUrl: resolveApiBaseUrl +}) + export const requestText = ( method: ApiHttpMethod, path: string, diff --git a/packages/app/src/web/api-project-core.ts b/packages/app/src/web/api-project-core.ts index 565b6143..5e180447 100644 --- a/packages/app/src/web/api-project-core.ts +++ b/packages/app/src/web/api-project-core.ts @@ -1,13 +1,13 @@ import { Effect } from "effect" import type { ApplyProjectRequest } from "../shared/project-resource-request.js" +import { dockerGitOpenApi } from "./api-http.js" import { baseCreateProjectBody, type CreateProjectRequestDraft, optionalProjectResourceFields } from "./api-project-create-body.js" import { OutputResponseSchema, ProjectResponseSchema } from "./api-schema.js" -import { openApiJsonSchema } from "./openapi-client.js" export type { ApplyProjectRequest, ProjectResourceLimitRequest } from "../shared/project-resource-request.js" export type { CreateProjectRequestDraft } from "./api-project-create-body.js" @@ -25,7 +25,7 @@ const createProjectBody = (draft: CreateProjectRequestDraft) => ({ }) export const loadProjectDetails = (projectId: string) => - openApiJsonSchema(ProjectResponseSchema, (client) => + dockerGitOpenApi.openApiJsonSchema(ProjectResponseSchema, (client) => client.GET("/projects/{projectId}", { params: { path: { projectId } } })).pipe( @@ -33,7 +33,7 @@ export const loadProjectDetails = (projectId: string) => ) export const loadProjectPs = (projectId: string) => - openApiJsonSchema(OutputResponseSchema, (client) => + dockerGitOpenApi.openApiJsonSchema(OutputResponseSchema, (client) => client.GET("/projects/{projectId}/ps", { params: { path: { projectId } } })).pipe( @@ -41,7 +41,7 @@ export const loadProjectPs = (projectId: string) => ) export const loadProjectLogs = (projectId: string) => - openApiJsonSchema(OutputResponseSchema, (client) => + dockerGitOpenApi.openApiJsonSchema(OutputResponseSchema, (client) => client.GET("/projects/{projectId}/logs", { params: { path: { projectId } } })).pipe( @@ -52,7 +52,7 @@ export const applyProject = ( projectId: string, request?: ApplyProjectRequest ) => - openApiJsonSchema(ProjectResponseSchema, (client) => + dockerGitOpenApi.openApiJsonSchema(ProjectResponseSchema, (client) => client.POST("/projects/{projectId}/apply", { body: applyProjectBody(request), params: { path: { projectId } } @@ -61,7 +61,7 @@ export const applyProject = ( ) export const createProject = (draft: CreateProjectRequestDraft) => - openApiJsonSchema(ProjectResponseSchema, (client) => + dockerGitOpenApi.openApiJsonSchema(ProjectResponseSchema, (client) => client.POST("/projects", { body: createProjectBody(draft) })).pipe( @@ -69,7 +69,7 @@ export const createProject = (draft: CreateProjectRequestDraft) => ) export const upProject = (projectId: string) => - openApiJsonSchema(ProjectResponseSchema, (client) => + dockerGitOpenApi.openApiJsonSchema(ProjectResponseSchema, (client) => client.POST("/projects/{projectId}/up", { body: { useManagedAuthorizedKeys: true }, params: { path: { projectId } } @@ -78,7 +78,7 @@ export const upProject = (projectId: string) => ) export const resumeProject = (projectId: string) => - openApiJsonSchema(ProjectResponseSchema, (client) => + dockerGitOpenApi.openApiJsonSchema(ProjectResponseSchema, (client) => client.POST("/projects/{projectId}/resume", { params: { path: { projectId } } })).pipe( @@ -86,7 +86,7 @@ export const resumeProject = (projectId: string) => ) export const suspendProject = (projectId: string) => - openApiJsonSchema(ProjectResponseSchema, (client) => + dockerGitOpenApi.openApiJsonSchema(ProjectResponseSchema, (client) => client.POST("/projects/{projectId}/suspend", { params: { path: { projectId } } })).pipe( diff --git a/packages/app/src/web/api-prompts.ts b/packages/app/src/web/api-prompts.ts index d040462f..660a07e5 100644 --- a/packages/app/src/web/api-prompts.ts +++ b/packages/app/src/web/api-prompts.ts @@ -1,37 +1,46 @@ import { Effect } from "effect" +import { dockerGitOpenApi } from "./api-http.js" import { ProjectPromptsResponseSchema, ProjectPromptUpdateResponseSchema } from "./api-schema.js" import type { ProjectPromptKind } from "./api-schema.js" -import { openApiJsonSchema } from "./openapi-client.js" export const loadProjectPrompts = (projectId: string) => - openApiJsonSchema(ProjectPromptsResponseSchema, (client) => - client.GET("/projects/{projectId}/prompts", { - params: { path: { projectId } } - })).pipe( - Effect.map((response) => response.snapshot) - ) + dockerGitOpenApi.openApiJsonSchema( + ProjectPromptsResponseSchema, + (client) => + client.GET("/projects/{projectId}/prompts", { + params: { path: { projectId } } + }) + ).pipe( + Effect.map((response) => response.snapshot) + ) export const writeProjectPrompt = ( projectId: string, kind: ProjectPromptKind, content: string ) => - openApiJsonSchema(ProjectPromptUpdateResponseSchema, (client) => - client.PUT("/projects/{projectId}/prompts/{kind}", { - body: { content }, - params: { path: { kind, projectId } } - })).pipe( - Effect.map((response) => response.snapshot) - ) + dockerGitOpenApi.openApiJsonSchema( + ProjectPromptUpdateResponseSchema, + (client) => + client.PUT("/projects/{projectId}/prompts/{kind}", { + body: { content }, + params: { path: { kind, projectId } } + }) + ).pipe( + Effect.map((response) => response.snapshot) + ) export const deleteProjectPrompt = ( projectId: string, kind: ProjectPromptKind ) => - openApiJsonSchema(ProjectPromptsResponseSchema, (client) => - client.DELETE("/projects/{projectId}/prompts/{kind}", { - params: { path: { kind, projectId } } - })).pipe( - Effect.map((response) => response.snapshot) - ) + dockerGitOpenApi.openApiJsonSchema( + ProjectPromptsResponseSchema, + (client) => + client.DELETE("/projects/{projectId}/prompts/{kind}", { + params: { path: { kind, projectId } } + }) + ).pipe( + Effect.map((response) => response.snapshot) + ) diff --git a/packages/app/src/web/api-share.ts b/packages/app/src/web/api-share.ts index fc1e4149..55c84901 100644 --- a/packages/app/src/web/api-share.ts +++ b/packages/app/src/web/api-share.ts @@ -1,7 +1,7 @@ import { Effect } from "effect" +import { dockerGitOpenApi } from "./api-http.js" import { PanelCloudflareTunnelResponseSchema } from "./api-schema.js" -import { openApiJsonSchema } from "./openapi-client.js" /** * Reads the controller-owned panel Cloudflare tunnel session. @@ -25,7 +25,10 @@ import { openApiJsonSchema } from "./openapi-client.js" // INVARIANT: Only schema-decoded tunnel state crosses the API boundary. // COMPLEXITY: O(1) local work plus network IO. export const loadPanelCloudflareTunnel = () => - openApiJsonSchema(PanelCloudflareTunnelResponseSchema, (client) => client.GET("/cloudflare-tunnels/panel")).pipe( + dockerGitOpenApi.openApiJsonSchema( + PanelCloudflareTunnelResponseSchema, + (client) => client.GET("/cloudflare-tunnels/panel") + ).pipe( Effect.map((response) => response.tunnel) ) @@ -51,12 +54,15 @@ export const loadPanelCloudflareTunnel = () => // INVARIANT: Returned state is decoded by PanelCloudflareTunnelResponseSchema. // COMPLEXITY: O(1) local work plus network IO and controller-side startup. export const startPanelCloudflareTunnel = (panelUrl: string) => - openApiJsonSchema(PanelCloudflareTunnelResponseSchema, (client) => - client.POST("/cloudflare-tunnels/panel", { - body: { panelUrl } - })).pipe( - Effect.map((response) => response.tunnel) - ) + dockerGitOpenApi.openApiJsonSchema( + PanelCloudflareTunnelResponseSchema, + (client) => + client.POST("/cloudflare-tunnels/panel", { + body: { panelUrl } + }) + ).pipe( + Effect.map((response) => response.tunnel) + ) /** * Stops the controller-owned panel Cloudflare tunnel. @@ -80,6 +86,9 @@ export const startPanelCloudflareTunnel = (panelUrl: string) => // INVARIANT: Returned state is decoded by PanelCloudflareTunnelResponseSchema. // COMPLEXITY: O(1) local work plus network IO and controller-side cleanup. export const stopPanelCloudflareTunnel = () => - openApiJsonSchema(PanelCloudflareTunnelResponseSchema, (client) => client.DELETE("/cloudflare-tunnels/panel")).pipe( + dockerGitOpenApi.openApiJsonSchema( + PanelCloudflareTunnelResponseSchema, + (client) => client.DELETE("/cloudflare-tunnels/panel") + ).pipe( Effect.map((response) => response.tunnel) ) diff --git a/packages/app/src/web/api-skills.ts b/packages/app/src/web/api-skills.ts index b0ffb147..fd6ca1b2 100644 --- a/packages/app/src/web/api-skills.ts +++ b/packages/app/src/web/api-skills.ts @@ -1,8 +1,8 @@ import { Effect } from "effect" +import { dockerGitOpenApi } from "./api-http.js" import { ProjectSkillsResponseSchema, ProjectSkillUpdateResponseSchema } from "./api-schema.js" import type { ProjectSkillScope } from "./api-schema.js" -import { openApiJsonSchema } from "./openapi-client.js" const skillScopeIdByScope: Readonly> = { "skills": "skills", @@ -17,12 +17,15 @@ const skillScopeIdByScope: Readonly> = { export const projectSkillScopeToId = (scope: ProjectSkillScope): string => skillScopeIdByScope[scope] export const loadProjectSkills = (projectId: string) => - openApiJsonSchema(ProjectSkillsResponseSchema, (client) => - client.GET("/projects/{projectId}/skills", { - params: { path: { projectId } } - })).pipe( - Effect.map((response) => response.snapshot) - ) + dockerGitOpenApi.openApiJsonSchema( + ProjectSkillsResponseSchema, + (client) => + client.GET("/projects/{projectId}/skills", { + params: { path: { projectId } } + }) + ).pipe( + Effect.map((response) => response.snapshot) + ) export const writeProjectSkill = ( projectId: string, @@ -30,20 +33,23 @@ export const writeProjectSkill = ( name: string, content: string ) => - openApiJsonSchema(ProjectSkillUpdateResponseSchema, (client) => - client.POST("/projects/{projectId}/skills", { - body: { content, name, scope }, - params: { path: { projectId } } - })).pipe( - Effect.map((response) => response.snapshot) - ) + dockerGitOpenApi.openApiJsonSchema( + ProjectSkillUpdateResponseSchema, + (client) => + client.POST("/projects/{projectId}/skills", { + body: { content, name, scope }, + params: { path: { projectId } } + }) + ).pipe( + Effect.map((response) => response.snapshot) + ) export const deleteProjectSkill = ( projectId: string, scope: ProjectSkillScope, name: string ) => - openApiJsonSchema( + dockerGitOpenApi.openApiJsonSchema( ProjectSkillsResponseSchema, (client) => client.DELETE("/projects/{projectId}/skills/{scopeId}/{name}", { diff --git a/packages/app/src/web/api-tasks.ts b/packages/app/src/web/api-tasks.ts index e4938207..eff20648 100644 --- a/packages/app/src/web/api-tasks.ts +++ b/packages/app/src/web/api-tasks.ts @@ -1,24 +1,27 @@ import { Effect } from "effect" +import { dockerGitOpenApi } from "./api-http.js" import { ContainerTaskSnapshotResponseSchema, OutputResponseSchema } from "./api-schema.js" -import { openApiJsonSchema, openApiVoid } from "./openapi-client.js" export const loadProjectTasks = (projectId: string, shouldIncludeDefault = false) => - openApiJsonSchema(ContainerTaskSnapshotResponseSchema, (client) => - client.GET("/projects/{projectId}/tasks", { - params: { - path: { projectId }, - query: shouldIncludeDefault ? { includeDefault: "true" } : {} - } - })).pipe( - Effect.map((response) => response.snapshot) - ) + dockerGitOpenApi.openApiJsonSchema( + ContainerTaskSnapshotResponseSchema, + (client) => + client.GET("/projects/{projectId}/tasks", { + params: { + path: { projectId }, + query: shouldIncludeDefault ? { includeDefault: "true" } : {} + } + }) + ).pipe( + Effect.map((response) => response.snapshot) + ) export const stopProjectTask = ( projectId: string, pid: number ) => - openApiVoid((client) => + dockerGitOpenApi.openApiVoid((client) => client.POST("/projects/{projectId}/tasks/{pid}/stop", { params: { path: { pid: String(pid), projectId } } }) @@ -29,12 +32,15 @@ export const loadProjectTaskLogs = ( pid: number, lines = 200 ) => - openApiJsonSchema(OutputResponseSchema, (client) => - client.GET("/projects/{projectId}/tasks/{pid}/logs", { - params: { - path: { pid: String(pid), projectId }, - query: { lines: String(lines) } - } - })).pipe( - Effect.map((response) => response.output) - ) + dockerGitOpenApi.openApiJsonSchema( + OutputResponseSchema, + (client) => + client.GET("/projects/{projectId}/tasks/{pid}/logs", { + params: { + path: { pid: String(pid), projectId }, + query: { lines: String(lines) } + } + }) + ).pipe( + Effect.map((response) => response.output) + ) diff --git a/packages/app/src/web/api-terminal.ts b/packages/app/src/web/api-terminal.ts index 47ca25e1..8c1e0b1b 100644 --- a/packages/app/src/web/api-terminal.ts +++ b/packages/app/src/web/api-terminal.ts @@ -1,5 +1,6 @@ import { Effect } from "effect" +import { dockerGitOpenApi } from "./api-http.js" import { AuthTerminalSessionResponseSchema, ProjectTerminalSessionResponseSchema, @@ -8,10 +9,9 @@ import { TerminalSessionLookupResponseSchema, TerminalSessionResponseSchema } from "./api-schema.js" -import { openApiJsonSchema, openApiVoid } from "./openapi-client.js" export const createProjectTerminalSession = (projectKey: string) => - openApiJsonSchema( + dockerGitOpenApi.openApiJsonSchema( TerminalSessionResponseSchema, (client) => client.POST("/projects/by-key/{projectKey}/terminal-sessions", { @@ -28,7 +28,7 @@ export const startProjectTerminalSession = ( projectKey: string, requestId: string ) => - openApiJsonSchema( + dockerGitOpenApi.openApiJsonSchema( StartProjectTerminalSessionAcceptedResponseSchema, (client) => client.POST("/projects/by-key/{projectKey}/terminal-sessions/start", { @@ -41,25 +41,28 @@ export const createAuthTerminalSession = ( flow: "ClaudeOauth" | "GeminiOauth" | "GrokOauth", label: string | null ) => - openApiJsonSchema(AuthTerminalSessionResponseSchema, (client) => - client.POST("/auth/terminal-sessions", { - body: { flow, label } - })).pipe( - Effect.map((response) => response.session) - ) + dockerGitOpenApi.openApiJsonSchema( + AuthTerminalSessionResponseSchema, + (client) => + client.POST("/auth/terminal-sessions", { + body: { flow, label } + }) + ).pipe( + Effect.map((response) => response.session) + ) export const deleteProjectTerminalSession = ( projectKey: string, sessionId: string ) => - openApiVoid((client) => + dockerGitOpenApi.openApiVoid((client) => client.DELETE("/projects/by-key/{projectKey}/terminal-sessions/{sessionId}", { params: { path: { projectKey, sessionId } } }) ) export const deleteAuthTerminalSession = (sessionId: string) => - openApiVoid((client) => + dockerGitOpenApi.openApiVoid((client) => client.DELETE("/auth/terminal-sessions/{sessionId}", { params: { path: { sessionId } } }) @@ -68,7 +71,7 @@ export const deleteAuthTerminalSession = (sessionId: string) => // WHY: panel UI needs only the sessions array for list rendering. // INVARIANT: this helper intentionally projects the full terminal workspace response to sessions. export const loadProjectTerminalSessions = (projectKey: string) => - openApiJsonSchema( + dockerGitOpenApi.openApiJsonSchema( ProjectTerminalSessionsResponseSchema, (client) => client.GET("/projects/by-key/{projectKey}/terminal-sessions", { @@ -81,7 +84,7 @@ export const loadProjectTerminalSessions = (projectKey: string) => // WHY: SSH-link initialization needs the full terminal workspace, including activeSessionId. // INVARIANT: this helper intentionally preserves the complete response shape. export const loadProjectTerminalWorkspace = (projectKey: string) => - openApiJsonSchema( + dockerGitOpenApi.openApiJsonSchema( ProjectTerminalSessionsResponseSchema, (client) => client.GET("/projects/by-key/{projectKey}/terminal-sessions", { @@ -93,7 +96,7 @@ export const setProjectActiveTerminalSession = ( projectKey: string, sessionId: string ) => - openApiJsonSchema( + dockerGitOpenApi.openApiJsonSchema( ProjectTerminalSessionResponseSchema, (client) => client.PUT("/projects/by-key/{projectKey}/terminal-sessions/active", { @@ -108,7 +111,7 @@ export const loadProjectTerminalSession = ( projectKey: string, sessionId: string ) => - openApiJsonSchema( + dockerGitOpenApi.openApiJsonSchema( ProjectTerminalSessionResponseSchema, (client) => client.GET("/projects/by-key/{projectKey}/terminal-sessions/{sessionId}", { @@ -119,10 +122,13 @@ export const loadProjectTerminalSession = ( ) export const loadTerminalSessionById = (sessionId: string) => - openApiJsonSchema(TerminalSessionLookupResponseSchema, (client) => - client.GET("/terminal-sessions/{sessionId}", { - params: { path: { sessionId } } - })) + dockerGitOpenApi.openApiJsonSchema( + TerminalSessionLookupResponseSchema, + (client) => + client.GET("/terminal-sessions/{sessionId}", { + params: { path: { sessionId } } + }) + ) const invalidTerminalClosePath = (path: string): string => `Invalid terminal close path: ${path}` diff --git a/packages/app/src/web/api.ts b/packages/app/src/web/api.ts index 3b2a71ef..44eec6e6 100644 --- a/packages/app/src/web/api.ts +++ b/packages/app/src/web/api.ts @@ -3,7 +3,7 @@ import { Effect } from "effect" import { sortSelectItemsByLaunchTime } from "../docker-git/menu-select-order.js" import type { SelectProjectRuntime } from "../docker-git/menu-types.js" import type { AuthMenuRequestBody, ProjectAuthMenuRequestBody } from "../shared/auth-menu-request.js" -import { requestJson, requestTextStream, resolveApiBaseUrl } from "./api-http.js" +import { dockerGitOpenApi, requestJson, requestTextStream, resolveApiBaseUrl } from "./api-http.js" import { AuthSnapshotResponseSchema, CodexStatusResponseSchema, @@ -25,7 +25,6 @@ import type { ProjectPortForward, ProjectSummary } from "./api-schema.js" -import { openApiJsonSchema, openApiVoid } from "./openapi-client.js" export { startCreateProject } from "./api-create-project.js" export { @@ -114,8 +113,8 @@ export const sortDashboardProjects = ( export const loadDashboard = (): Effect.Effect => Effect.all({ - health: openApiJsonSchema(HealthResponseSchema, (client) => client.GET("/health")), - projectsResponse: openApiJsonSchema(ProjectsResponseSchema, (client) => client.GET("/projects")) + health: dockerGitOpenApi.openApiJsonSchema(HealthResponseSchema, (client) => client.GET("/health")), + projectsResponse: dockerGitOpenApi.openApiJsonSchema(ProjectsResponseSchema, (client) => client.GET("/projects")) }).pipe( Effect.map(({ health, projectsResponse }) => ({ apiBaseUrl: resolveApiBaseUrl(), @@ -143,25 +142,34 @@ export const openSkiller = (projectKey?: string, sessionId?: string) => ) export const loadProjectPortForwards = (projectId: string) => - openApiJsonSchema(ProjectPortForwardsResponseSchema, (client) => - client.GET("/projects/{projectId}/ports", { - params: { path: { projectId } } - })).pipe( - Effect.map((response) => response.forwards) - ) + dockerGitOpenApi.openApiJsonSchema( + ProjectPortForwardsResponseSchema, + (client) => + client.GET("/projects/{projectId}/ports", { + params: { path: { projectId } } + }) + ).pipe( + Effect.map((response) => response.forwards) + ) export const loadProjectBrowser = (projectId: string) => - openApiJsonSchema(ProjectBrowserResponseSchema, (client) => - client.GET("/projects/{projectId}/browser", { - params: { path: { projectId } } - })) + dockerGitOpenApi.openApiJsonSchema( + ProjectBrowserResponseSchema, + (client) => + client.GET("/projects/{projectId}/browser", { + params: { path: { projectId } } + }) + ) .pipe(Effect.map((response) => response.browser)) export const startProjectBrowser = (projectId: string) => - openApiJsonSchema(ProjectBrowserResponseSchema, (client) => - client.POST("/projects/{projectId}/browser/start", { - params: { path: { projectId } } - })) + dockerGitOpenApi.openApiJsonSchema( + ProjectBrowserResponseSchema, + (client) => + client.POST("/projects/{projectId}/browser/start", { + params: { path: { projectId } } + }) + ) .pipe(Effect.map((response) => response.browser)) export const createProjectPortForward = ( @@ -169,54 +177,57 @@ export const createProjectPortForward = ( targetPort: number, hostPort?: number ) => - openApiJsonSchema(ProjectPortForwardResponseSchema, (client) => - client.POST("/projects/{projectId}/ports", { - body: hostPort === undefined ? { targetPort } : { hostPort, targetPort }, - params: { path: { projectId } } - })).pipe( - Effect.map((response) => response.forward) - ) + dockerGitOpenApi.openApiJsonSchema( + ProjectPortForwardResponseSchema, + (client) => + client.POST("/projects/{projectId}/ports", { + body: hostPort === undefined ? { targetPort } : { hostPort, targetPort }, + params: { path: { projectId } } + }) + ).pipe( + Effect.map((response) => response.forward) + ) export const deleteProjectPortForward = ( projectId: string, targetPort: number ) => - openApiVoid((client) => + dockerGitOpenApi.openApiVoid((client) => client.DELETE("/projects/{projectId}/ports/{targetPort}", { params: { path: { projectId, targetPort: String(targetPort) } } }) ) export const downProject = (projectId: string) => - openApiVoid((client) => + dockerGitOpenApi.openApiVoid((client) => client.POST("/projects/{projectId}/down", { params: { path: { projectId } } }) ) export const deleteProject = (projectId: string) => - openApiVoid((client) => + dockerGitOpenApi.openApiVoid((client) => client.DELETE("/projects/{projectId}", { params: { path: { projectId } } }) ) -export const downAllProjects = () => openApiVoid((client) => client.POST("/projects/down-all")) +export const downAllProjects = () => dockerGitOpenApi.openApiVoid((client) => client.POST("/projects/down-all")) export const applyAllProjects = (shouldApplyActiveOnly: boolean) => - openApiVoid((client) => + dockerGitOpenApi.openApiVoid((client) => client.POST("/projects/apply-all", { body: { activeOnly: shouldApplyActiveOnly } }) ) export const loadGithubStatus = () => - openApiJsonSchema(GithubStatusResponseSchema, (client) => client.GET("/auth/github/status")).pipe( + dockerGitOpenApi.openApiJsonSchema(GithubStatusResponseSchema, (client) => client.GET("/auth/github/status")).pipe( Effect.map((response) => response.status) ) export const loginGithub = (label: string | null) => - openApiJsonSchema(GithubStatusResponseSchema, (client) => + dockerGitOpenApi.openApiJsonSchema(GithubStatusResponseSchema, (client) => client.POST("/auth/github/login", { body: { label } })).pipe( @@ -240,7 +251,7 @@ export const loginCodexStream = (label: string | null, onChunk: (chunk: string) }) export const logoutCodex = (label: string | null) => - openApiJsonSchema(CodexStatusResponseSchema, (client) => + dockerGitOpenApi.openApiJsonSchema(CodexStatusResponseSchema, (client) => client.POST("/auth/codex/logout", { body: { label } })).pipe(Effect.asVoid) @@ -258,34 +269,43 @@ export const loadProjectEvents = ( ) export const loadAuthSnapshot = () => - openApiJsonSchema(AuthSnapshotResponseSchema, (client) => client.GET("/auth/menu")).pipe( + dockerGitOpenApi.openApiJsonSchema(AuthSnapshotResponseSchema, (client) => client.GET("/auth/menu")).pipe( Effect.map((response) => response.snapshot) ) export const runAuthMenuFlow = (request: AuthMenuRequestBody & { readonly flow: AuthMenuFlow }) => - openApiJsonSchema(AuthSnapshotResponseSchema, (client) => client.POST("/auth/menu", { body: request })).pipe( + dockerGitOpenApi.openApiJsonSchema( + AuthSnapshotResponseSchema, + (client) => client.POST("/auth/menu", { body: request }) + ).pipe( Effect.map((response) => response.snapshot) ) export const loadProjectAuthSnapshot = (projectId: string) => - openApiJsonSchema(ProjectAuthSnapshotResponseSchema, (client) => - client.GET("/projects/{projectId}/auth/menu", { - params: { path: { projectId } } - })).pipe( - Effect.map((response) => response.snapshot) - ) + dockerGitOpenApi.openApiJsonSchema( + ProjectAuthSnapshotResponseSchema, + (client) => + client.GET("/projects/{projectId}/auth/menu", { + params: { path: { projectId } } + }) + ).pipe( + Effect.map((response) => response.snapshot) + ) export const runProjectAuthFlow = ( projectId: string, request: ProjectAuthMenuRequestBody & { readonly flow: ProjectAuthFlow } ) => - openApiJsonSchema(ProjectAuthSnapshotResponseSchema, (client) => - client.POST("/projects/{projectId}/auth/menu", { - body: request, - params: { path: { projectId } } - })).pipe( - Effect.map((response) => response.snapshot) - ) + dockerGitOpenApi.openApiJsonSchema( + ProjectAuthSnapshotResponseSchema, + (client) => + client.POST("/projects/{projectId}/auth/menu", { + body: request, + params: { path: { projectId } } + }) + ).pipe( + Effect.map((response) => response.snapshot) + ) export { resolveApiBaseUrl } from "./api-http.js" diff --git a/packages/app/src/web/openapi-client.ts b/packages/app/src/web/openapi-client.ts deleted file mode 100644 index bdb26bbf..00000000 --- a/packages/app/src/web/openapi-client.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { makeDockerGitOpenApiRuntime } from "@prover-coder-ai/docker-git-openapi" - -import { resolveApiBaseUrl } from "./api-http.js" - -const openApiRuntime = makeDockerGitOpenApiRuntime({ - resolveBaseUrl: resolveApiBaseUrl -}) - -/** - * Executes a docker-git OpenAPI JSON request against the current browser API base URL. - * - * @pure false - performs HTTP IO when the returned Effect is run. - * @effect openapi-fetch request wrapped in Effect. - * @invariant the shared OpenAPI runtime owns transport decoding and error rendering. - * @precondition request uses generated docker-git OpenAPI paths. - * @postcondition success contains the endpoint data branch as a JSON transport value. - * @complexity O(n)/O(n) for error rendering, O(1)/O(1) on local success handling. - * @throws Never; failures are returned in the Effect error channel. - */ -export const openApiJson: typeof openApiRuntime.openApiJson = openApiRuntime.openApiJson - -/** - * Executes a docker-git OpenAPI request and decodes the response with an Effect Schema. - * - * @pure false - performs HTTP IO and boundary decoding when the returned Effect is run. - * @effect openapi-fetch request plus synchronous Schema decoding. - * @invariant generated transport shapes are decoded before leaving the web API boundary. - * @precondition schema matches the endpoint success response contract. - * @postcondition success contains the schema-decoded DTO expected by UI code. - * @complexity O(n)/O(n) where n is the decoded response size. - * @throws Never; failures are returned in the Effect error channel. - */ -export const openApiJsonSchema: typeof openApiRuntime.openApiJsonSchema = openApiRuntime.openApiJsonSchema - -/** - * Executes a docker-git OpenAPI request whose success response has no body. - * - * @pure false - performs HTTP IO when the returned Effect is run. - * @effect openapi-fetch request wrapped in Effect. - * @invariant only the HTTP success status determines the void success branch. - * @precondition request targets an endpoint whose successful response has no content. - * @postcondition success returns void without exposing transport details. - * @complexity O(n)/O(n) for error rendering, O(1)/O(1) on local success handling. - * @throws Never; failures are returned in the Effect error channel. - */ -export const openApiVoid: typeof openApiRuntime.openApiVoid = openApiRuntime.openApiVoid diff --git a/packages/app/tests/docker-git/api-terminal.test.ts b/packages/app/tests/docker-git/api-terminal.test.ts index 4d5e2bed..54a94c22 100644 --- a/packages/app/tests/docker-git/api-terminal.test.ts +++ b/packages/app/tests/docker-git/api-terminal.test.ts @@ -32,9 +32,11 @@ const openApiVoidMock = vi.hoisted(() => }) ) -vi.mock("../../src/web/openapi-client.js", () => ({ - openApiJsonSchema: vi.fn(), - openApiVoid: openApiVoidMock +vi.mock("../../src/web/api-http.js", () => ({ + dockerGitOpenApi: { + openApiJsonSchema: vi.fn(), + openApiVoid: openApiVoidMock + } })) describe("api terminal helpers", () => { diff --git a/packages/app/tests/docker-git/openapi-effect-client.test.ts b/packages/app/tests/docker-git/openapi-effect-client.test.ts new file mode 100644 index 00000000..070de5c4 --- /dev/null +++ b/packages/app/tests/docker-git/openapi-effect-client.test.ts @@ -0,0 +1,124 @@ +import * as Schema from "@effect/schema/Schema" +import { describe, expect, it } from "@effect/vitest" +import { createClient } from "@prover-coder-ai/docker-git-openapi" +import type { ApiTransportValue } from "@prover-coder-ai/docker-git-openapi" +import { Effect } from "effect" + +type CapturedRequest = { + readonly headers: Headers + readonly method: string + readonly url: string +} + +const HealthResponseSchema = Schema.Struct({ + cwd: Schema.String, + ok: Schema.Boolean, + projectsRoot: Schema.String, + revision: Schema.NullOr(Schema.String) +}) + +const createJsonResponse = (status: number, value: ApiTransportValue): Response => + Response.json(value, { + headers: { + "content-type": "application/json" + }, + status + }) + +const createMockFetch = ( + requests: Array, + response: Response +): (request: Request) => ReturnType => +(request) => { + requests.push({ + headers: request.headers, + method: request.method, + url: request.url + }) + return Effect.runPromise(Effect.succeed(response)) +} + +describe("docker-git OpenAPI Effect client", () => { + it.effect("executes typed GET requests through openapi-effect and decodes JSON with Schema", () => + Effect.gen(function*(_) { + const requests: Array = [] + const api = createClient({ + fetch: createMockFetch( + requests, + createJsonResponse(200, { + cwd: "/workspace", + ok: true, + projectsRoot: "/workspace/projects", + revision: null + }) + ), + resolveBaseUrl: () => "https://docker-git.example.test" + }) + + const decoded = yield* _(api.openApiJsonSchema(HealthResponseSchema, (client) => client.GET("/health"))) + + expect(decoded).toEqual({ + cwd: "/workspace", + ok: true, + projectsRoot: "/workspace/projects", + revision: null + }) + expect(requests).toHaveLength(1) + expect(requests[0]?.method).toBe("GET") + expect(requests[0]?.headers.get("accept")).toBe("application/json") + expect(requests[0]?.headers.get("cache-control")).toContain("no-cache") + expect(new URL(requests[0]?.url ?? "").searchParams.has("_")).toBe(true) + })) + + it.effect("renders nested API error envelopes from openapi-effect failures", () => + Effect.gen(function*(_) { + const api = createClient({ + fetch: createMockFetch( + [], + createJsonResponse(500, { + error: { + message: "container snapshot failed", + type: "Internal" + } + }) + ), + resolveBaseUrl: () => "https://docker-git.example.test" + }) + + const result = yield* _( + Effect.either(api.openApiJsonSchema(HealthResponseSchema, (client) => client.GET("/health"))) + ) + + expect(result._tag).toBe("Left") + if (result._tag === "Left") { + expect(result.left).toContain("container snapshot failed") + } + })) + + it.effect("preserves JSON null as a valid raw transport value", () => + Effect.gen(function*(_) { + const api = createClient({ + fetch: createMockFetch([], createJsonResponse(200, null)), + resolveBaseUrl: () => "https://docker-git.example.test" + }) + + const value = yield* _(api.openApiJson((client) => client.GET("/health"))) + + expect(value).toBeNull() + })) + + it.effect("treats 200 ok command responses as successful void effects", () => + Effect.gen(function*(_) { + const requests: Array = [] + const api = createClient({ + fetch: createMockFetch(requests, createJsonResponse(200, { ok: true })), + resolveBaseUrl: () => "https://docker-git.example.test" + }) + + yield* _(api.openApiVoid((client) => client.POST("/projects/down-all"))) + + expect(requests).toHaveLength(1) + expect(requests[0]?.method).toBe("POST") + expect(new URL(requests[0]?.url ?? "").pathname).toBe("/projects/down-all") + })) +}) diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json index 25156f10..15e14d69 100644 --- a/packages/app/tsconfig.json +++ b/packages/app/tsconfig.json @@ -8,6 +8,7 @@ "lib": ["ES2023", "DOM", "DOM.Iterable"], "jsx": "react-jsx", "paths": { + "@prover-coder-ai/openapi-effect": ["../openapi/src/types/openapi-effect.d.ts"], "@/*": ["./src/*"] } }, diff --git a/packages/openapi/package.json b/packages/openapi/package.json index 0e748a23..f4774637 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -28,8 +28,9 @@ }, "dependencies": { "@effect/schema": "^0.75.5", + "@prover-coder-ai/openapi-effect": "^1.0.22", "effect": "^3.21.3", - "openapi-fetch": "^0.17.0" + "openapi-typescript-helpers": "^0.1.0" }, "devDependencies": { "openapi-typescript": "^7.13.0", diff --git a/packages/openapi/src/client.ts b/packages/openapi/src/client.ts index 208b9418..411bb198 100644 --- a/packages/openapi/src/client.ts +++ b/packages/openapi/src/client.ts @@ -1,12 +1,15 @@ import * as ParseResult from "@effect/schema/ParseResult" import type * as Schema from "@effect/schema/Schema" import * as TreeFormatter from "@effect/schema/TreeFormatter" +import { createClientEffect } from "@prover-coder-ai/openapi-effect" +import type { ClientOptions, Middleware } from "@prover-coder-ai/openapi-effect" import { Effect, Either, Option } from "effect" -import createClient, { type Client, type Middleware } from "openapi-fetch" import type { paths } from "./openapi-paths.js" -export type DockerGitOpenApiClient = Client +export type DockerGitOpenApiTransportClient = ReturnType> + +type DockerGitOpenApiMiddleware = Middleware export type ApiTransportValue = | undefined @@ -17,23 +20,22 @@ export type ApiTransportValue = | ReadonlyArray | { readonly [key: string]: ApiTransportValue } -export type ApiTransportError = ApiTransportValue | object - export type OpenApiResponse = { readonly data?: A - readonly error?: ApiTransportError + readonly error?: unknown readonly response: Response } -export type OpenApiRequestResult = PromiseLike> +export type OpenApiRequestResult = Effect.Effect, Error> -export type OpenApiRequest = (client: DockerGitOpenApiClient) => OpenApiRequestResult +export type OpenApiRequest = (client: DockerGitOpenApiTransportClient) => OpenApiRequestResult -export type DockerGitOpenApiRuntimeOptions = { +export type DockerGitOpenApiClientOptions = { + readonly fetch?: ClientOptions["fetch"] readonly resolveBaseUrl: () => string } -export type DockerGitOpenApiRuntime = { +export type DockerGitOpenApiClient = { readonly openApiJson: (request: OpenApiRequest) => Effect.Effect readonly openApiJsonSchema: ( schema: Schema.Schema, @@ -42,7 +44,7 @@ export type DockerGitOpenApiRuntime = { readonly openApiVoid: (request: OpenApiRequest) => Effect.Effect } -type RunOpenApi = (request: OpenApiRequest) => Effect.Effect, string> +type RunOpenApi = (request: OpenApiRequest) => Effect.Effect, string> const noCacheHeaders: Readonly> = { accept: "application/json", @@ -50,18 +52,18 @@ const noCacheHeaders: Readonly> = { pragma: "no-cache" } -const stringifyJson = (value: ApiTransportError): Effect.Effect => +const stringifyJson = (value: unknown): Effect.Effect => Effect.try({ try: () => JSON.stringify(value, null, 2), catch: () => null }) -const safeJson = (value: ApiTransportError): Effect.Effect => +const safeJson = (value: unknown): Effect.Effect => stringifyJson(value).pipe( Effect.orElseSucceed(() => "unrenderable response payload") ) -const renderTransportValue = (value: ApiTransportError): Effect.Effect => { +const renderTransportValue = (value: unknown): Effect.Effect => { if (typeof value === "string") { return Effect.succeed(value) } @@ -74,9 +76,35 @@ const renderTransportValue = (value: ApiTransportError): Effect.Effect = return safeJson(value) } +const isApiTransportValue = (value: unknown): value is ApiTransportValue => { + if ( + value === undefined + || value === null + || typeof value === "boolean" + || typeof value === "number" + || typeof value === "string" + ) { + return true + } + if (Array.isArray(value)) { + return value.every(isApiTransportValue) + } + if (typeof value !== "object") { + return false + } + return Object.values(value).every(isApiTransportValue) +} + +const decodeTransportValue = (value: unknown): Effect.Effect => + isApiTransportValue(value) + ? Effect.succeed(value) + : renderTransportValue(value).pipe( + Effect.flatMap((rendered) => Effect.fail(`Invalid JSON response payload: ${rendered}`)) + ) + const renderOpenApiError = ( response: Response, - error: ApiTransportError | undefined + error: unknown ): Effect.Effect => { if (response.status === 429) { return Effect.succeed("HTTP 429: tunnel or proxy rate limited the request. Retry or request a fresh tunnel URL.") @@ -84,7 +112,7 @@ const renderOpenApiError = ( return error === undefined ? Effect.succeed(`HTTP ${response.status}`) : renderTransportValue(error) } -const noCacheGetMiddleware: Middleware = { +const noCacheGetMiddleware: DockerGitOpenApiMiddleware = { onRequest: ({ request }) => { if (request.method !== "GET") { return @@ -96,24 +124,35 @@ const noCacheGetMiddleware: Middleware = { } /** - * Creates a typed openapi-fetch client for the docker-git JSON REST API. + * Creates a typed openapi-effect transport client for the docker-git JSON REST API. * * @param baseUrl - Absolute API base URL. - * @returns Typed OpenAPI client with no-cache headers and GET cache-busting middleware. + * @param fetch - Optional fetch implementation for tests or custom runtimes. + * @returns Typed Effect OpenAPI transport client with no-cache headers and GET cache-busting middleware. * * @pure false - constructs a browser/Fetch API HTTP client adapter. - * @effect none - client construction only; network IO happens when request methods are executed. + * @effect none - client construction only; returned methods describe network IO as Effect. * @invariant client paths are constrained by generated DockerGit OpenAPI paths. * @precondition baseUrl points at a docker-git API server or compatible proxy. * @postcondition returned client sends no-cache headers on JSON requests. * @complexity O(1)/O(1) * @throws Never. */ -export const createDockerGitOpenApiClient = (baseUrl: string): DockerGitOpenApiClient => { - const client = createClient({ - baseUrl, - headers: noCacheHeaders - }) +export const createTransportClient = ( + baseUrl: string, + fetch?: ClientOptions["fetch"] +): DockerGitOpenApiTransportClient => { + const clientOptions: ClientOptions = fetch === undefined + ? { + baseUrl, + headers: noCacheHeaders + } + : { + baseUrl, + fetch, + headers: noCacheHeaders + } + const client = createClientEffect(clientOptions) client.use(noCacheGetMiddleware) return client } @@ -121,30 +160,29 @@ export const createDockerGitOpenApiClient = (baseUrl: string): DockerGitOpenApiC /** * Runs a typed OpenAPI request with a provided client through Effect. * - * @param client - Typed docker-git OpenAPI client. - * @param request - Deferred openapi-fetch request. + * @param client - Typed docker-git OpenAPI transport client. + * @param request - Deferred openapi-effect request. * @returns Effect containing raw transport response data or a string failure. * - * @pure false - executes Promise-producing openapi-fetch request when the Effect is run. - * @effect Promise interop isolated through Effect.tryPromise. - * @invariant no Promise escapes the function boundary. + * @pure false - executes an Effect-producing openapi-effect request when the Effect is run. + * @effect Network request represented directly as Effect. + * @invariant no Promise interop is required at this boundary. * @precondition request was built against the same generated OpenAPI path map as client. * @postcondition transport failures are represented in the Effect error channel. * @complexity O(1)/O(1) excluding network and response body costs. * @throws Never. */ export const runOpenApi = ( - client: DockerGitOpenApiClient, + client: DockerGitOpenApiTransportClient, request: OpenApiRequest -): Effect.Effect, string> => - Effect.tryPromise({ - try: () => request(client), - catch: String - }) +): Effect.Effect, string> => + request(client).pipe( + Effect.mapError(String) + ) const failRenderedOpenApiError = ( response: Response, - error: ApiTransportError | undefined + error: unknown ): Effect.Effect => renderOpenApiError(response, error).pipe( Effect.flatMap((message) => Effect.fail(message)) @@ -159,17 +197,16 @@ const openApiJsonWithRunner = ( Option.match(Option.fromNullable(error), { onNone: () => response.ok - ? Option.match(Option.fromNullable(data), { - onNone: () => Effect.fail(`HTTP ${response.status}: empty response`), - onSome: (value) => Effect.succeed(value) - }) + ? data === undefined + ? Effect.fail(`HTTP ${response.status}: empty response`) + : decodeTransportValue(data) : failRenderedOpenApiError(response, error), onSome: (apiError) => failRenderedOpenApiError(response, apiError) }) ) ) -const decodeSchema = (schema: Schema.Schema, value: ApiTransportValue): Effect.Effect => +const decodeSchema = (schema: Schema.Schema, value: unknown): Effect.Effect => Either.match(ParseResult.decodeUnknownEither(schema)(value), { onLeft: (error) => Effect.fail(TreeFormatter.formatIssueSync(error)), onRight: (decoded) => Effect.succeed(decoded) @@ -202,20 +239,20 @@ const openApiVoidWithRunner = ( /** * Executes a typed OpenAPI JSON request through a provided client. * - * @param client - Typed docker-git OpenAPI client. - * @param request - Deferred typed openapi-fetch request. + * @param client - Typed docker-git OpenAPI transport client. + * @param request - Deferred typed openapi-effect request. * @returns Effect containing raw 2xx response data or a rendered API error. * * @pure false - performs browser HTTP IO when the Effect is run. - * @effect Network request via openapi-fetch wrapped by Effect.tryPromise. - * @invariant Promise interop is isolated inside this boundary. + * @effect Network request via openapi-effect. + * @invariant request execution remains Effect-native at this boundary. * @precondition request uses a static path from generated OpenAPI paths. * @postcondition successful Effect contains only the 2xx data branch as a transport value. * @complexity O(n) local response rendering where n is the error payload size. * @throws Never; failures are returned in the Effect error channel. */ export const openApiJson = ( - client: DockerGitOpenApiClient, + client: DockerGitOpenApiTransportClient, request: OpenApiRequest ): Effect.Effect => openApiJsonWithRunner((nextRequest) => runOpenApi(client, nextRequest), request) @@ -223,13 +260,13 @@ export const openApiJson = ( /** * Executes a typed OpenAPI request and decodes the data with an Effect Schema. * - * @param client - Typed docker-git OpenAPI client. + * @param client - Typed docker-git OpenAPI transport client. * @param schema - Boundary decoder preserving the consumer DTO type. - * @param request - Deferred typed openapi-fetch request. + * @param request - Deferred typed openapi-effect request. * @returns Effect containing schema-decoded response data. * * @pure false - performs browser HTTP IO and boundary decoding when the Effect is run. - * @effect openapi-fetch request plus synchronous Effect Schema decoding. + * @effect openapi-effect request plus synchronous Effect Schema decoding. * @invariant transport typing comes from OpenAPI; exported data typing comes from Schema. * @precondition schema matches the endpoint success response documented in DockerGitApi. * @postcondition no generated optional/default representation leaks into existing consumers. @@ -237,7 +274,7 @@ export const openApiJson = ( * @throws Never; failures are returned in the Effect error channel. */ export const openApiJsonSchema = ( - client: DockerGitOpenApiClient, + client: DockerGitOpenApiTransportClient, schema: Schema.Schema, request: OpenApiRequest ): Effect.Effect => @@ -246,12 +283,12 @@ export const openApiJsonSchema = ( /** * Executes a typed OpenAPI request whose successful response has no body. * - * @param client - Typed docker-git OpenAPI client. - * @param request - Deferred typed openapi-fetch request. + * @param client - Typed docker-git OpenAPI transport client. + * @param request - Deferred typed openapi-effect request. * @returns Effect that succeeds with void for successful empty responses. * * @pure false - performs browser HTTP IO when the Effect is run. - * @effect Network request via openapi-fetch wrapped by Effect.tryPromise. + * @effect Network request via openapi-effect. * @invariant only response status determines success for empty endpoints. * @precondition request targets an endpoint whose OpenAPI success response has no content. * @postcondition successful Effect returns void and never exposes transport details. @@ -259,50 +296,49 @@ export const openApiJsonSchema = ( * @throws Never; failures are returned in the Effect error channel. */ export const openApiVoid = ( - client: DockerGitOpenApiClient, + client: DockerGitOpenApiTransportClient, request: OpenApiRequest ): Effect.Effect => openApiVoidWithRunner((nextRequest) => runOpenApi(client, nextRequest), request) /** - * Creates reusable Effect helpers backed by a base URL resolver. + * Creates a reusable Effect OpenAPI client backed by a base URL resolver. * - * @param options - Runtime configuration containing a base URL resolver. - * @returns OpenAPI helper set with a baseUrl-keyed client cache. + * @param options - Client configuration containing a base URL resolver. + * @returns OpenAPI client with a baseUrl-keyed transport cache. * * @pure false - closes over mutable client cache for client reuse in a shell boundary. * @effect none during construction; returned helpers perform HTTP IO when their Effects run. * @invariant cache is keyed only by resolved baseUrl and invalidated on baseUrl change. * @precondition resolveBaseUrl is deterministic for the duration of a single request Effect. - * @postcondition consumers can share OpenAPI helpers without importing app-specific base URL logic. + * @postcondition consumers can share one configured OpenAPI client without importing transport details. * @complexity O(1)/O(1) for client lookup, excluding request execution. * @throws Never. */ -export const makeDockerGitOpenApiRuntime = ( - options: DockerGitOpenApiRuntimeOptions -): DockerGitOpenApiRuntime => { +export const createClient = ( + options: DockerGitOpenApiClientOptions +): DockerGitOpenApiClient => { const clientCache: { baseUrl: string | null - client: DockerGitOpenApiClient | null + client: DockerGitOpenApiTransportClient | null } = { baseUrl: null, client: null } - const getOpenApiClient = (): DockerGitOpenApiClient => { + const getOpenApiClient = (): DockerGitOpenApiTransportClient => { const baseUrl = options.resolveBaseUrl() if (clientCache.client === null || clientCache.baseUrl !== baseUrl) { clientCache.baseUrl = baseUrl - clientCache.client = createDockerGitOpenApiClient(baseUrl) + clientCache.client = createTransportClient(baseUrl, options.fetch) } return clientCache.client } - const runRuntimeOpenApi = (request: OpenApiRequest): Effect.Effect, string> => - Effect.tryPromise({ - try: () => request(getOpenApiClient()), - catch: String - }) + const runRuntimeOpenApi = (request: OpenApiRequest): Effect.Effect, string> => + request(getOpenApiClient()).pipe( + Effect.mapError(String) + ) return { openApiJson: (request) => openApiJsonWithRunner(runRuntimeOpenApi, request), diff --git a/packages/openapi/src/types/openapi-effect.d.ts b/packages/openapi/src/types/openapi-effect.d.ts new file mode 100644 index 00000000..558815d6 --- /dev/null +++ b/packages/openapi/src/types/openapi-effect.d.ts @@ -0,0 +1,223 @@ +declare module "@prover-coder-ai/openapi-effect" { + import type { Effect } from "effect" + import type { + FilterKeys, + HttpMethod, + IsOperationRequestBodyOptional, + OperationRequestBodyContent, + PathsWithMethod, + RequiredKeysOf, + Writable + } from "openapi-typescript-helpers" + + export type HeadersOptions = + | Required["headers"] + | Readonly< + Record< + string, + | string + | number + | boolean + | ReadonlyArray + | null + | undefined + > + > + + export type QuerySerializer = ( + query: T extends { parameters: infer Parameters } + ? Parameters extends { query?: infer Query } ? NonNullable : Record + : Record + ) => string + + export type QuerySerializerOptions = { + readonly array?: { + readonly style: "form" | "spaceDelimited" | "pipeDelimited" + readonly explode: boolean + } + readonly object?: { + readonly style: "form" | "deepObject" + readonly explode: boolean + } + readonly allowReserved?: boolean + } + + export type BodySerializer = ( + body: Writable> | BodyInit | object, + headers?: Headers | HeadersOptions + ) => BodyInit + + export type PathSerializer = ( + pathname: string, + pathParams: Readonly> + ) => string + + export type ParseAs = "json" | "text" | "blob" | "arrayBuffer" | "stream" + + export interface ClientOptions extends Omit { + readonly baseUrl?: string + readonly fetch?: (input: Request) => ReturnType + readonly Request?: typeof Request + readonly querySerializer?: QuerySerializer | QuerySerializerOptions + readonly bodySerializer?: BodySerializer + readonly pathSerializer?: PathSerializer + readonly headers?: HeadersOptions + readonly requestInitExt?: Record + } + + export interface MiddlewareRequestParams { + readonly query?: Record + readonly header?: Record + readonly path?: Record + readonly cookie?: Record + } + + export interface MiddlewareCallbackParams { + readonly request: Request + readonly schemaPath: string + readonly params: MiddlewareRequestParams + readonly id: string + readonly options: { + readonly baseUrl: string + readonly parseAs: ParseAs + readonly querySerializer: QuerySerializer + readonly bodySerializer: BodySerializer + readonly pathSerializer: PathSerializer + readonly fetch: typeof globalThis.fetch + } + } + + export type Thenable = { + readonly then: ( + onFulfilled: (value: T) => unknown, + onRejected?: (reason: unknown) => unknown + ) => unknown + } + + export type AsyncValue = T | Thenable + + export type MiddlewareOnRequest = ( + options: MiddlewareCallbackParams + ) => AsyncValue + + export type MiddlewareOnResponse = ( + options: MiddlewareCallbackParams & { readonly response: Response } + ) => AsyncValue + + export type MiddlewareOnError = ( + options: MiddlewareCallbackParams & { readonly error: unknown } + ) => AsyncValue + + export type Middleware = + | { + readonly onRequest: MiddlewareOnRequest + readonly onResponse?: MiddlewareOnResponse + readonly onError?: MiddlewareOnError + } + | { + readonly onRequest?: MiddlewareOnRequest + readonly onResponse: MiddlewareOnResponse + readonly onError?: MiddlewareOnError + } + | { + readonly onRequest?: MiddlewareOnRequest + readonly onResponse?: MiddlewareOnResponse + readonly onError: MiddlewareOnError + } + + export interface DefaultParamsOption { + readonly params?: { + readonly query?: Record + } + } + + export type ParamsOption = T extends { parameters: infer Parameters } + ? RequiredKeysOf extends never ? { readonly params?: Parameters } + : { readonly params: Parameters } + : DefaultParamsOption + + export type RequestBodyOption = Writable> extends never + ? { readonly body?: never } + : IsOperationRequestBodyOptional extends true + ? { readonly body?: Writable> } + : { readonly body: Writable> } + + export type RequestOptions = + & ParamsOption + & RequestBodyOption + & Omit + & { + readonly baseUrl?: string + readonly querySerializer?: QuerySerializer | QuerySerializerOptions + readonly bodySerializer?: BodySerializer + readonly pathSerializer?: PathSerializer + readonly parseAs?: ParseAs + readonly fetch?: ClientOptions["fetch"] + readonly headers?: HeadersOptions + readonly middleware?: ReadonlyArray + } + + export type MaybeOptionalInit = RequiredKeysOf< + RequestOptions> + > extends never ? RequestOptions> | undefined + : RequestOptions> + + export type InitParam = RequiredKeysOf extends never + ? [(Init & { readonly [key: string]: unknown })?] + : [Init & { readonly [key: string]: unknown }] + + export type OperationFor< + Paths extends object, + Path extends keyof Paths, + Method extends HttpMethod + > = Paths[Path] extends Record ? Operation & Record + : never + + export type MethodResult = Effect.Effect< + { + readonly data?: unknown + readonly error?: unknown + readonly response: Response + }, + Error + > + + export type ClientMethod< + Paths extends object, + Method extends HttpMethod + > = < + Path extends PathsWithMethod, + Init extends MaybeOptionalInit> + >( + url: Path, + ...init: InitParam + ) => MethodResult + + export type ClientRequestMethod = < + Method extends HttpMethod, + Path extends PathsWithMethod, + Init extends MaybeOptionalInit> + >( + method: Method, + url: Path, + ...init: InitParam + ) => MethodResult + + export interface ClientEffect { + readonly request: ClientRequestMethod + readonly GET: ClientMethod + readonly PUT: ClientMethod + readonly POST: ClientMethod + readonly DELETE: ClientMethod + readonly OPTIONS: ClientMethod + readonly HEAD: ClientMethod + readonly PATCH: ClientMethod + readonly TRACE: ClientMethod + use(...middleware: ReadonlyArray): void + eject(...middleware: ReadonlyArray): void + } + + export const createClientEffect: ( + clientOptions?: ClientOptions + ) => ClientEffect +} diff --git a/packages/openapi/tsconfig.json b/packages/openapi/tsconfig.json index b244d935..0871f60e 100644 --- a/packages/openapi/tsconfig.json +++ b/packages/openapi/tsconfig.json @@ -3,6 +3,9 @@ "compilerOptions": { "rootDir": ".", "lib": ["ES2023", "DOM"], + "paths": { + "@prover-coder-ai/openapi-effect": ["./src/types/openapi-effect.d.ts"] + }, "types": [] }, "include": ["src/**/*"], From 7d9deb62bb19c6ca6ec0c6d24751a66ab0a6eaff Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:40:34 +0000 Subject: [PATCH 2/8] refactor(openapi): use published openapi-effect types --- bun.lock | 51 +--- packages/app/tsconfig.json | 1 - packages/openapi/package.json | 5 +- packages/openapi/src/client.ts | 89 +++---- .../openapi/src/types/openapi-effect.d.ts | 223 ------------------ packages/openapi/tsconfig.json | 3 - 6 files changed, 52 insertions(+), 320 deletions(-) delete mode 100644 packages/openapi/src/types/openapi-effect.d.ts diff --git a/bun.lock b/bun.lock index c42148d6..61b83688 100644 --- a/bun.lock +++ b/bun.lock @@ -43,7 +43,7 @@ }, "packages/app": { "name": "@prover-coder-ai/docker-git", - "version": "1.3.10", + "version": "1.3.12", "bin": { "docker-git": "dist/src/docker-git/main.js", }, @@ -154,7 +154,7 @@ }, "packages/docker-git-session-sync": { "name": "@prover-coder-ai/docker-git-session-sync", - "version": "1.0.66", + "version": "1.0.68", "bin": { "docker-git-session-sync": "dist/docker-git-session-sync.js", }, @@ -237,9 +237,8 @@ "version": "0.1.0", "dependencies": { "@effect/schema": "^0.75.5", - "@prover-coder-ai/openapi-effect": "^1.0.22", + "@prover-coder-ai/openapi-effect": "^1.0.27", "effect": "^3.21.3", - "openapi-typescript-helpers": "^0.1.0", }, "devDependencies": { "openapi-typescript": "^7.13.0", @@ -677,7 +676,7 @@ "@prover-coder-ai/eslint-plugin-suggest-members": ["@prover-coder-ai/eslint-plugin-suggest-members@0.0.26", "", { "dependencies": { "@effect/platform": "^0.96.0", "@effect/platform-node": "^0.106.0", "@effect/schema": "^0.75.5", "@typescript-eslint/utils": "8.57.2", "effect": "^3.21.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <7.0.0" } }, "sha512-RWl1jYZTMK1p0L6GA7VXvTrtiNkbQyjkgk3mvz0Vv7ImTrctDOLFfNIRoJmhU+e5irj1u5uK2p9QoZtRzi4ILQ=="], - "@prover-coder-ai/openapi-effect": ["@prover-coder-ai/openapi-effect@1.0.22", "", { "dependencies": { "@effect/platform": "^0.94.5", "@effect/platform-node": "^0.104.1", "@effect/schema": "^0.75.5", "effect": "^3.19.18" } }, "sha512-4M5TTZAnr9SrlksjvV356GgdK2Bg1iEAuP6iYqatBq2nWvHgcLFQvS8SQtE0GquEOR73C/1FbOAzZ0Nrs9aFUQ=="], + "@prover-coder-ai/openapi-effect": ["@prover-coder-ai/openapi-effect@1.0.27", "", { "dependencies": { "effect": "^3.19.18", "openapi-typescript-helpers": "^0.1.0" } }, "sha512-OTz993XzEQdowlf/W64lAnQg1TdP+J/miA5C0w2pO8eqEG32BQ13lUhs6/hDiKkEagxo15coTH4YazHwjPtiBQ=="], "@redocly/ajv": ["@redocly/ajv@8.11.2", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js-replace": "^1.0.1" } }, "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg=="], @@ -2051,10 +2050,6 @@ "@prover-coder-ai/eslint-plugin-suggest-members/effect": ["effect@3.21.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-rXd2FGDM8KdjSIrc+mqEELo7ScW7xTVxEf1iInmPSpIde9/nyGuFM710cjTo7/EreGXiUX2MOonPpprbz2XHCg=="], - "@prover-coder-ai/openapi-effect/@effect/platform": ["@effect/platform@0.94.5", "", { "dependencies": { "find-my-way-ts": "0.1.6", "msgpackr": "1.11.5", "multipasta": "0.2.7" }, "peerDependencies": { "effect": "3.21.0" } }, "sha512-z05APUiDDPbodhTkH/RJqOLoCU11bU2IZLfcwLFrld03+ob1VeqRnELQlmueLIYm6NZifHAtjl32V+GRt34y4A=="], - - "@prover-coder-ai/openapi-effect/@effect/platform-node": ["@effect/platform-node@0.104.1", "", { "dependencies": { "@effect/platform-node-shared": "0.57.1", "mime": "3.0.0", "undici": "7.16.0", "ws": "8.18.3" }, "peerDependencies": { "@effect/cluster": "0.58.0", "@effect/platform": "0.94.5", "@effect/rpc": "0.75.0", "@effect/sql": "0.51.0", "effect": "3.21.0" } }, "sha512-jT1a/z98niK6fnEU8pWHPPCdJMVDRCIdB65lolcOjse5rsTwVbczMjvKkhVQpF63mNWoOnol7OTRNkw5L54llg=="], - "@redocly/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@redocly/openapi-core/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], @@ -2331,22 +2326,6 @@ "@prover-coder-ai/eslint-plugin-suggest-members/effect/fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], - "@prover-coder-ai/openapi-effect/@effect/platform/effect": ["effect@3.21.0", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "fast-check": "3.23.2" } }, "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ=="], - - "@prover-coder-ai/openapi-effect/@effect/platform/msgpackr": ["msgpackr@1.11.5", "", { "optionalDependencies": { "msgpackr-extract": "3.0.3" } }, "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA=="], - - "@prover-coder-ai/openapi-effect/@effect/platform-node/@effect/cluster": ["@effect/cluster@0.58.0", "", { "dependencies": { "kubernetes-types": "1.30.0" }, "peerDependencies": { "@effect/platform": "0.96.0", "@effect/rpc": "0.75.0", "@effect/sql": "0.51.0", "@effect/workflow": "0.18.0", "effect": "3.21.0" } }, "sha512-0Zog7s7XdntWcTqdqWPoj6nc7hPaWIzp0k0DsFUWyCynXNPK9dAtgFrSce04NhddNqqbhtZck/lhuqJwNBrprQ=="], - - "@prover-coder-ai/openapi-effect/@effect/platform-node/@effect/platform-node-shared": ["@effect/platform-node-shared@0.57.1", "", { "dependencies": { "@parcel/watcher": "2.5.1", "multipasta": "0.2.7", "ws": "8.18.3" }, "peerDependencies": { "@effect/cluster": "0.58.0", "@effect/platform": "0.94.5", "@effect/rpc": "0.75.0", "@effect/sql": "0.51.0", "effect": "3.21.0" } }, "sha512-oX/bApMdoKsyrDiNdJxo7U9Rz1RXsjRv+ecfAPp1qGlSdGIo32wVRvJ2XCHqYj0sqaYJS0pU0/GCulRfVGuJag=="], - - "@prover-coder-ai/openapi-effect/@effect/platform-node/@effect/rpc": ["@effect/rpc@0.75.0", "", { "dependencies": { "msgpackr": "1.11.5" }, "peerDependencies": { "@effect/platform": "0.96.0", "effect": "3.21.0" } }, "sha512-VFeJ16cZUXqiIzG9UHOVKGuiBPJ7fV+0lEbJU6xi12JnnxXe/19BQPpOwiRawCUbPOR3/xIURDUgGxU+Ft0pvQ=="], - - "@prover-coder-ai/openapi-effect/@effect/platform-node/@effect/sql": ["@effect/sql@0.51.0", "", { "dependencies": { "uuid": "11.1.0" }, "peerDependencies": { "@effect/experimental": "0.60.0", "@effect/platform": "0.96.0", "effect": "3.21.0" } }, "sha512-e7hWe46QD15eMCr4kNBMVdItIVK/WLHJG+d8DLL1FjVf5Ra82k2mwUYIXplJewVbHjt3my6GSKPPd1ZrQjVd5A=="], - - "@prover-coder-ai/openapi-effect/@effect/platform-node/effect": ["effect@3.21.0", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "fast-check": "3.23.2" } }, "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ=="], - - "@prover-coder-ai/openapi-effect/@effect/platform-node/ws": ["ws@8.18.3", "", {}, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], - "@redocly/openapi-core/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "1.0.2" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "@ton-ai-core/vibecode-linter/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -2539,20 +2518,6 @@ "@prover-coder-ai/eslint-plugin-suggest-members/effect/fast-check/pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], - "@prover-coder-ai/openapi-effect/@effect/platform-node/@effect/cluster/@effect/platform": ["@effect/platform@0.96.0", "", { "dependencies": { "find-my-way-ts": "0.1.6", "msgpackr": "1.11.5", "multipasta": "0.2.7" }, "peerDependencies": { "effect": "3.21.0" } }, "sha512-U7PLhkVzg7zzrgFvyWATOzD6reL87KG/fcdOxgLWBQ/J5CCU6qdPAVG+0o6o+IxcsLoqGwxs+rFxaFzrdtDV1A=="], - - "@prover-coder-ai/openapi-effect/@effect/platform-node/@effect/cluster/@effect/workflow": ["@effect/workflow@0.18.0", "", { "peerDependencies": { "@effect/experimental": "0.60.0", "@effect/platform": "0.96.0", "@effect/rpc": "0.75.0", "effect": "3.21.0" } }, "sha512-9Zp+x9ADtR0H6CRhU6wLyPcIRjO1PXjvSpUlFlBQ8piw7ldjPmnUWEY8YQuH6eExV2dalQ4z2LMiZ5Bd7XAJbA=="], - - "@prover-coder-ai/openapi-effect/@effect/platform-node/@effect/rpc/@effect/platform": ["@effect/platform@0.96.0", "", { "dependencies": { "find-my-way-ts": "0.1.6", "msgpackr": "1.11.5", "multipasta": "0.2.7" }, "peerDependencies": { "effect": "3.21.0" } }, "sha512-U7PLhkVzg7zzrgFvyWATOzD6reL87KG/fcdOxgLWBQ/J5CCU6qdPAVG+0o6o+IxcsLoqGwxs+rFxaFzrdtDV1A=="], - - "@prover-coder-ai/openapi-effect/@effect/platform-node/@effect/rpc/msgpackr": ["msgpackr@1.11.5", "", { "optionalDependencies": { "msgpackr-extract": "3.0.3" } }, "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA=="], - - "@prover-coder-ai/openapi-effect/@effect/platform-node/@effect/sql/@effect/platform": ["@effect/platform@0.96.0", "", { "dependencies": { "find-my-way-ts": "0.1.6", "msgpackr": "1.11.5", "multipasta": "0.2.7" }, "peerDependencies": { "effect": "3.21.0" } }, "sha512-U7PLhkVzg7zzrgFvyWATOzD6reL87KG/fcdOxgLWBQ/J5CCU6qdPAVG+0o6o+IxcsLoqGwxs+rFxaFzrdtDV1A=="], - - "@prover-coder-ai/openapi-effect/@effect/platform-node/effect/fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], - - "@prover-coder-ai/openapi-effect/@effect/platform/effect/fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], - "@redocly/openapi-core/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@ton-ai-core/vibecode-linter/effect/fast-check/pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], @@ -2661,14 +2626,6 @@ "@prover-coder-ai/eslint-plugin-suggest-members/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="], - "@prover-coder-ai/openapi-effect/@effect/platform-node/@effect/cluster/@effect/platform/msgpackr": ["msgpackr@1.11.5", "", { "optionalDependencies": { "msgpackr-extract": "3.0.3" } }, "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA=="], - - "@prover-coder-ai/openapi-effect/@effect/platform-node/@effect/sql/@effect/platform/msgpackr": ["msgpackr@1.11.5", "", { "optionalDependencies": { "msgpackr-extract": "3.0.3" } }, "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA=="], - - "@prover-coder-ai/openapi-effect/@effect/platform-node/effect/fast-check/pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], - - "@prover-coder-ai/openapi-effect/@effect/platform/effect/fast-check/pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], - "eslint-module-utils/@typescript-eslint/parser/@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "4.0.4" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], "eslint-module-utils/@typescript-eslint/parser/@typescript-eslint/typescript-estree/tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json index 15e14d69..25156f10 100644 --- a/packages/app/tsconfig.json +++ b/packages/app/tsconfig.json @@ -8,7 +8,6 @@ "lib": ["ES2023", "DOM", "DOM.Iterable"], "jsx": "react-jsx", "paths": { - "@prover-coder-ai/openapi-effect": ["../openapi/src/types/openapi-effect.d.ts"], "@/*": ["./src/*"] } }, diff --git a/packages/openapi/package.json b/packages/openapi/package.json index f4774637..28eedc94 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -28,9 +28,8 @@ }, "dependencies": { "@effect/schema": "^0.75.5", - "@prover-coder-ai/openapi-effect": "^1.0.22", - "effect": "^3.21.3", - "openapi-typescript-helpers": "^0.1.0" + "@prover-coder-ai/openapi-effect": "^1.0.27", + "effect": "^3.21.3" }, "devDependencies": { "openapi-typescript": "^7.13.0", diff --git a/packages/openapi/src/client.ts b/packages/openapi/src/client.ts index 411bb198..0ed7ecc9 100644 --- a/packages/openapi/src/client.ts +++ b/packages/openapi/src/client.ts @@ -2,8 +2,8 @@ import * as ParseResult from "@effect/schema/ParseResult" import type * as Schema from "@effect/schema/Schema" import * as TreeFormatter from "@effect/schema/TreeFormatter" import { createClientEffect } from "@prover-coder-ai/openapi-effect" -import type { ClientOptions, Middleware } from "@prover-coder-ai/openapi-effect" -import { Effect, Either, Option } from "effect" +import type { BoundaryError, ClientOptions, Middleware } from "@prover-coder-ai/openapi-effect" +import { Effect, Either, Match } from "effect" import type { paths } from "./openapi-paths.js" @@ -20,13 +20,19 @@ export type ApiTransportValue = | ReadonlyArray | { readonly [key: string]: ApiTransportValue } -export type OpenApiResponse = { - readonly data?: A - readonly error?: unknown - readonly response: Response +export type OpenApiSuccess = { + readonly status: number | string + readonly contentType: string + readonly body: unknown } -export type OpenApiRequestResult = Effect.Effect, Error> +export type OpenApiHttpError = OpenApiSuccess & { + readonly _tag: "HttpError" +} + +export type OpenApiFailure = OpenApiHttpError | BoundaryError + +export type OpenApiRequestResult = Effect.Effect export type OpenApiRequest = (client: DockerGitOpenApiTransportClient) => OpenApiRequestResult @@ -44,7 +50,7 @@ export type DockerGitOpenApiClient = { readonly openApiVoid: (request: OpenApiRequest) => Effect.Effect } -type RunOpenApi = (request: OpenApiRequest) => Effect.Effect, string> +type RunOpenApi = (request: OpenApiRequest) => Effect.Effect const noCacheHeaders: Readonly> = { accept: "application/json", @@ -102,14 +108,13 @@ const decodeTransportValue = (value: unknown): Effect.Effect Effect.fail(`Invalid JSON response payload: ${rendered}`)) ) -const renderOpenApiError = ( - response: Response, - error: unknown +const renderOpenApiHttpError = ( + error: OpenApiHttpError ): Effect.Effect => { - if (response.status === 429) { + if (String(error.status) === "429") { return Effect.succeed("HTTP 429: tunnel or proxy rate limited the request. Retry or request a fresh tunnel URL.") } - return error === undefined ? Effect.succeed(`HTTP ${response.status}`) : renderTransportValue(error) + return error.body === undefined ? Effect.succeed(`HTTP ${error.status}`) : renderTransportValue(error.body) } const noCacheGetMiddleware: DockerGitOpenApiMiddleware = { @@ -175,16 +180,27 @@ export const createTransportClient = ( export const runOpenApi = ( client: DockerGitOpenApiTransportClient, request: OpenApiRequest -): Effect.Effect, string> => - request(client).pipe( - Effect.mapError(String) +): Effect.Effect => request(client) + +const renderOpenApiFailure = (failure: OpenApiFailure): Effect.Effect => + Match.value(failure).pipe( + Match.when({ _tag: "HttpError" }, renderOpenApiHttpError), + Match.when({ _tag: "TransportError" }, (error) => Effect.succeed(error.error.message)), + Match.when({ _tag: "UnexpectedStatus" }, (error) => Effect.succeed(`HTTP ${error.status}: ${error.body}`)), + Match.when({ _tag: "UnexpectedContentType" }, (error) => + Effect.succeed(`HTTP ${error.status}: unexpected content type ${error.actual ?? "none"}: ${error.body}`) + ), + Match.when({ _tag: "ParseError" }, (error) => + Effect.succeed(`HTTP ${error.status}: invalid ${error.contentType} response: ${error.error.message}`) + ), + Match.when({ _tag: "DecodeError" }, (error) => + Effect.succeed(`HTTP ${error.status}: invalid decoded response: ${error.error.message}`) + ), + Match.exhaustive ) -const failRenderedOpenApiError = ( - response: Response, - error: unknown -): Effect.Effect => - renderOpenApiError(response, error).pipe( +const failRenderedOpenApiFailure = (failure: OpenApiFailure): Effect.Effect => + renderOpenApiFailure(failure).pipe( Effect.flatMap((message) => Effect.fail(message)) ) @@ -193,16 +209,11 @@ const openApiJsonWithRunner = ( request: OpenApiRequest ): Effect.Effect => runner(request).pipe( - Effect.flatMap(({ data, error, response }) => - Option.match(Option.fromNullable(error), { - onNone: () => - response.ok - ? data === undefined - ? Effect.fail(`HTTP ${response.status}: empty response`) - : decodeTransportValue(data) - : failRenderedOpenApiError(response, error), - onSome: (apiError) => failRenderedOpenApiError(response, apiError) - }) + Effect.catchAll(failRenderedOpenApiFailure), + Effect.flatMap((success) => + success.body === undefined + ? Effect.fail(`HTTP ${success.status}: empty response`) + : decodeTransportValue(success.body) ) ) @@ -226,14 +237,8 @@ const openApiVoidWithRunner = ( request: OpenApiRequest ): Effect.Effect => runner(request).pipe( - Effect.flatMap(({ error, response }) => - response.ok - ? Option.match(Option.fromNullable(error), { - onNone: () => Effect.void, - onSome: (apiError) => failRenderedOpenApiError(response, apiError) - }) - : failRenderedOpenApiError(response, error) - ) + Effect.asVoid, + Effect.catchAll(failRenderedOpenApiFailure) ) /** @@ -335,10 +340,8 @@ export const createClient = ( return clientCache.client } - const runRuntimeOpenApi = (request: OpenApiRequest): Effect.Effect, string> => - request(getOpenApiClient()).pipe( - Effect.mapError(String) - ) + const runRuntimeOpenApi = (request: OpenApiRequest): Effect.Effect => + request(getOpenApiClient()) return { openApiJson: (request) => openApiJsonWithRunner(runRuntimeOpenApi, request), diff --git a/packages/openapi/src/types/openapi-effect.d.ts b/packages/openapi/src/types/openapi-effect.d.ts deleted file mode 100644 index 558815d6..00000000 --- a/packages/openapi/src/types/openapi-effect.d.ts +++ /dev/null @@ -1,223 +0,0 @@ -declare module "@prover-coder-ai/openapi-effect" { - import type { Effect } from "effect" - import type { - FilterKeys, - HttpMethod, - IsOperationRequestBodyOptional, - OperationRequestBodyContent, - PathsWithMethod, - RequiredKeysOf, - Writable - } from "openapi-typescript-helpers" - - export type HeadersOptions = - | Required["headers"] - | Readonly< - Record< - string, - | string - | number - | boolean - | ReadonlyArray - | null - | undefined - > - > - - export type QuerySerializer = ( - query: T extends { parameters: infer Parameters } - ? Parameters extends { query?: infer Query } ? NonNullable : Record - : Record - ) => string - - export type QuerySerializerOptions = { - readonly array?: { - readonly style: "form" | "spaceDelimited" | "pipeDelimited" - readonly explode: boolean - } - readonly object?: { - readonly style: "form" | "deepObject" - readonly explode: boolean - } - readonly allowReserved?: boolean - } - - export type BodySerializer = ( - body: Writable> | BodyInit | object, - headers?: Headers | HeadersOptions - ) => BodyInit - - export type PathSerializer = ( - pathname: string, - pathParams: Readonly> - ) => string - - export type ParseAs = "json" | "text" | "blob" | "arrayBuffer" | "stream" - - export interface ClientOptions extends Omit { - readonly baseUrl?: string - readonly fetch?: (input: Request) => ReturnType - readonly Request?: typeof Request - readonly querySerializer?: QuerySerializer | QuerySerializerOptions - readonly bodySerializer?: BodySerializer - readonly pathSerializer?: PathSerializer - readonly headers?: HeadersOptions - readonly requestInitExt?: Record - } - - export interface MiddlewareRequestParams { - readonly query?: Record - readonly header?: Record - readonly path?: Record - readonly cookie?: Record - } - - export interface MiddlewareCallbackParams { - readonly request: Request - readonly schemaPath: string - readonly params: MiddlewareRequestParams - readonly id: string - readonly options: { - readonly baseUrl: string - readonly parseAs: ParseAs - readonly querySerializer: QuerySerializer - readonly bodySerializer: BodySerializer - readonly pathSerializer: PathSerializer - readonly fetch: typeof globalThis.fetch - } - } - - export type Thenable = { - readonly then: ( - onFulfilled: (value: T) => unknown, - onRejected?: (reason: unknown) => unknown - ) => unknown - } - - export type AsyncValue = T | Thenable - - export type MiddlewareOnRequest = ( - options: MiddlewareCallbackParams - ) => AsyncValue - - export type MiddlewareOnResponse = ( - options: MiddlewareCallbackParams & { readonly response: Response } - ) => AsyncValue - - export type MiddlewareOnError = ( - options: MiddlewareCallbackParams & { readonly error: unknown } - ) => AsyncValue - - export type Middleware = - | { - readonly onRequest: MiddlewareOnRequest - readonly onResponse?: MiddlewareOnResponse - readonly onError?: MiddlewareOnError - } - | { - readonly onRequest?: MiddlewareOnRequest - readonly onResponse: MiddlewareOnResponse - readonly onError?: MiddlewareOnError - } - | { - readonly onRequest?: MiddlewareOnRequest - readonly onResponse?: MiddlewareOnResponse - readonly onError: MiddlewareOnError - } - - export interface DefaultParamsOption { - readonly params?: { - readonly query?: Record - } - } - - export type ParamsOption = T extends { parameters: infer Parameters } - ? RequiredKeysOf extends never ? { readonly params?: Parameters } - : { readonly params: Parameters } - : DefaultParamsOption - - export type RequestBodyOption = Writable> extends never - ? { readonly body?: never } - : IsOperationRequestBodyOptional extends true - ? { readonly body?: Writable> } - : { readonly body: Writable> } - - export type RequestOptions = - & ParamsOption - & RequestBodyOption - & Omit - & { - readonly baseUrl?: string - readonly querySerializer?: QuerySerializer | QuerySerializerOptions - readonly bodySerializer?: BodySerializer - readonly pathSerializer?: PathSerializer - readonly parseAs?: ParseAs - readonly fetch?: ClientOptions["fetch"] - readonly headers?: HeadersOptions - readonly middleware?: ReadonlyArray - } - - export type MaybeOptionalInit = RequiredKeysOf< - RequestOptions> - > extends never ? RequestOptions> | undefined - : RequestOptions> - - export type InitParam = RequiredKeysOf extends never - ? [(Init & { readonly [key: string]: unknown })?] - : [Init & { readonly [key: string]: unknown }] - - export type OperationFor< - Paths extends object, - Path extends keyof Paths, - Method extends HttpMethod - > = Paths[Path] extends Record ? Operation & Record - : never - - export type MethodResult = Effect.Effect< - { - readonly data?: unknown - readonly error?: unknown - readonly response: Response - }, - Error - > - - export type ClientMethod< - Paths extends object, - Method extends HttpMethod - > = < - Path extends PathsWithMethod, - Init extends MaybeOptionalInit> - >( - url: Path, - ...init: InitParam - ) => MethodResult - - export type ClientRequestMethod = < - Method extends HttpMethod, - Path extends PathsWithMethod, - Init extends MaybeOptionalInit> - >( - method: Method, - url: Path, - ...init: InitParam - ) => MethodResult - - export interface ClientEffect { - readonly request: ClientRequestMethod - readonly GET: ClientMethod - readonly PUT: ClientMethod - readonly POST: ClientMethod - readonly DELETE: ClientMethod - readonly OPTIONS: ClientMethod - readonly HEAD: ClientMethod - readonly PATCH: ClientMethod - readonly TRACE: ClientMethod - use(...middleware: ReadonlyArray): void - eject(...middleware: ReadonlyArray): void - } - - export const createClientEffect: ( - clientOptions?: ClientOptions - ) => ClientEffect -} diff --git a/packages/openapi/tsconfig.json b/packages/openapi/tsconfig.json index 0871f60e..b244d935 100644 --- a/packages/openapi/tsconfig.json +++ b/packages/openapi/tsconfig.json @@ -3,9 +3,6 @@ "compilerOptions": { "rootDir": ".", "lib": ["ES2023", "DOM"], - "paths": { - "@prover-coder-ai/openapi-effect": ["./src/types/openapi-effect.d.ts"] - }, "types": [] }, "include": ["src/**/*"], From a603c4ee48b722928f33c918327427173cdc53f9 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:18:17 +0000 Subject: [PATCH 3/8] fix(openapi): tighten client boundary tests --- packages/api/src/http.ts | 2 +- .../docker-git/openapi-effect-client.test.ts | 61 ++++++++++- packages/openapi/src/client.ts | 101 ++---------------- 3 files changed, 67 insertions(+), 97 deletions(-) diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 618a3366..aeb9497a 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -318,7 +318,7 @@ const textResponse = (data: string, contentType: string, status = 200) => // WHY: generated clients and humans must inspect the same OpenAPI document. // QUOTE(ТЗ): "использовать Swagger" // REF: user-message-2026-06-19-openapi-effect -// SOURCE: n/a +// SOURCE: https://github.com/ProverCoderAI/openapi-effect // FORMAT THEOREM: docsPath(d) = p -> openApiPath(d) = sibling(p, "openapi.json") // PURITY: CORE // EFFECT: none diff --git a/packages/app/tests/docker-git/openapi-effect-client.test.ts b/packages/app/tests/docker-git/openapi-effect-client.test.ts index 070de5c4..535b24a7 100644 --- a/packages/app/tests/docker-git/openapi-effect-client.test.ts +++ b/packages/app/tests/docker-git/openapi-effect-client.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest" import { createClient } from "@prover-coder-ai/docker-git-openapi" import type { ApiTransportValue } from "@prover-coder-ai/docker-git-openapi" import { Effect } from "effect" +import * as fc from "fast-check" type CapturedRequest = { readonly headers: Headers @@ -38,6 +39,26 @@ const createMockFetch = ( return Effect.runPromise(Effect.succeed(response)) } +/** + * Runs a fast-check async property inside the Effect test runtime. + * + * @param property - Finite property whose cases execute Effect-backed OpenAPI requests. + * @returns Effect that fails when fast-check finds a counterexample. + * + * @pure false - executes property samples. + * @effect Effect.tryPromise, fc.assert. + * @invariant success proves every sampled case preserved the asserted client invariant. + * @precondition property cases do not share mutable request capture arrays. + * @postcondition counterexamples are surfaced through the Effect error channel. + * @complexity O(r * c) where r is numRuns and c is one request case cost. + * @throws Never. + */ +const assertOpenApiClientProperty = (property: fc.IAsyncProperty) => + Effect.tryPromise({ + catch: (cause) => cause, + try: () => fc.assert(property, { numRuns: 25 }) + }) + describe("docker-git OpenAPI Effect client", () => { it.effect("executes typed GET requests through openapi-effect and decodes JSON with Schema", () => Effect.gen(function*(_) { @@ -70,6 +91,42 @@ describe("docker-git OpenAPI Effect client", () => { expect(new URL(requests[0]?.url ?? "").searchParams.has("_")).toBe(true) })) + it.effect("property: GET requests always include no-cache transport invariants", () => + assertOpenApiClientProperty( + fc.asyncProperty( + fc.webUrl().map((url) => new URL(url).origin), + (baseUrl) => + Effect.runPromise( + Effect.gen(function*(_) { + const requests: Array = [] + const api = createClient({ + fetch: createMockFetch( + requests, + createJsonResponse(200, { + cwd: "/workspace", + ok: true, + projectsRoot: "/workspace/projects", + revision: null + }) + ), + resolveBaseUrl: () => baseUrl + }) + + const result = yield* _( + Effect.either(api.openApiJsonSchema(HealthResponseSchema, (client) => client.GET("/health"))) + ) + + expect(result._tag).toBe("Right") + expect(requests).toHaveLength(1) + expect(requests[0]?.method).toBe("GET") + expect(requests[0]?.headers.get("accept")).toBe("application/json") + expect(requests[0]?.headers.get("cache-control")).toContain("no-cache") + expect(new URL(requests[0]?.url ?? "").searchParams.has("_")).toBe(true) + }) + ) + ) + )) + it.effect("renders nested API error envelopes from openapi-effect failures", () => Effect.gen(function*(_) { const api = createClient({ @@ -95,14 +152,14 @@ describe("docker-git OpenAPI Effect client", () => { } })) - it.effect("preserves JSON null as a valid raw transport value", () => + it.effect("preserves JSON null as a valid schema-decoded transport value", () => Effect.gen(function*(_) { const api = createClient({ fetch: createMockFetch([], createJsonResponse(200, null)), resolveBaseUrl: () => "https://docker-git.example.test" }) - const value = yield* _(api.openApiJson((client) => client.GET("/health"))) + const value = yield* _(api.openApiJsonSchema(Schema.Null, (client) => client.GET("/health"))) expect(value).toBeNull() })) diff --git a/packages/openapi/src/client.ts b/packages/openapi/src/client.ts index 0ed7ecc9..735d925f 100644 --- a/packages/openapi/src/client.ts +++ b/packages/openapi/src/client.ts @@ -7,7 +7,7 @@ import { Effect, Either, Match } from "effect" import type { paths } from "./openapi-paths.js" -export type DockerGitOpenApiTransportClient = ReturnType> +type DockerGitOpenApiTransportClient = ReturnType> type DockerGitOpenApiMiddleware = Middleware @@ -20,21 +20,21 @@ export type ApiTransportValue = | ReadonlyArray | { readonly [key: string]: ApiTransportValue } -export type OpenApiSuccess = { +type OpenApiSuccess = { readonly status: number | string readonly contentType: string readonly body: unknown } -export type OpenApiHttpError = OpenApiSuccess & { +type OpenApiHttpError = OpenApiSuccess & { readonly _tag: "HttpError" } -export type OpenApiFailure = OpenApiHttpError | BoundaryError +type OpenApiFailure = OpenApiHttpError | BoundaryError -export type OpenApiRequestResult = Effect.Effect +type OpenApiRequestResult = Effect.Effect -export type OpenApiRequest = (client: DockerGitOpenApiTransportClient) => OpenApiRequestResult +type OpenApiRequest = (client: DockerGitOpenApiTransportClient) => OpenApiRequestResult export type DockerGitOpenApiClientOptions = { readonly fetch?: ClientOptions["fetch"] @@ -42,7 +42,6 @@ export type DockerGitOpenApiClientOptions = { } export type DockerGitOpenApiClient = { - readonly openApiJson: (request: OpenApiRequest) => Effect.Effect readonly openApiJsonSchema: ( schema: Schema.Schema, request: OpenApiRequest @@ -143,7 +142,7 @@ const noCacheGetMiddleware: DockerGitOpenApiMiddleware = { * @complexity O(1)/O(1) * @throws Never. */ -export const createTransportClient = ( +const createTransportClient = ( baseUrl: string, fetch?: ClientOptions["fetch"] ): DockerGitOpenApiTransportClient => { @@ -162,26 +161,6 @@ export const createTransportClient = ( return client } -/** - * Runs a typed OpenAPI request with a provided client through Effect. - * - * @param client - Typed docker-git OpenAPI transport client. - * @param request - Deferred openapi-effect request. - * @returns Effect containing raw transport response data or a string failure. - * - * @pure false - executes an Effect-producing openapi-effect request when the Effect is run. - * @effect Network request represented directly as Effect. - * @invariant no Promise interop is required at this boundary. - * @precondition request was built against the same generated OpenAPI path map as client. - * @postcondition transport failures are represented in the Effect error channel. - * @complexity O(1)/O(1) excluding network and response body costs. - * @throws Never. - */ -export const runOpenApi = ( - client: DockerGitOpenApiTransportClient, - request: OpenApiRequest -): Effect.Effect => request(client) - const renderOpenApiFailure = (failure: OpenApiFailure): Effect.Effect => Match.value(failure).pipe( Match.when({ _tag: "HttpError" }, renderOpenApiHttpError), @@ -241,71 +220,6 @@ const openApiVoidWithRunner = ( Effect.catchAll(failRenderedOpenApiFailure) ) -/** - * Executes a typed OpenAPI JSON request through a provided client. - * - * @param client - Typed docker-git OpenAPI transport client. - * @param request - Deferred typed openapi-effect request. - * @returns Effect containing raw 2xx response data or a rendered API error. - * - * @pure false - performs browser HTTP IO when the Effect is run. - * @effect Network request via openapi-effect. - * @invariant request execution remains Effect-native at this boundary. - * @precondition request uses a static path from generated OpenAPI paths. - * @postcondition successful Effect contains only the 2xx data branch as a transport value. - * @complexity O(n) local response rendering where n is the error payload size. - * @throws Never; failures are returned in the Effect error channel. - */ -export const openApiJson = ( - client: DockerGitOpenApiTransportClient, - request: OpenApiRequest -): Effect.Effect => - openApiJsonWithRunner((nextRequest) => runOpenApi(client, nextRequest), request) - -/** - * Executes a typed OpenAPI request and decodes the data with an Effect Schema. - * - * @param client - Typed docker-git OpenAPI transport client. - * @param schema - Boundary decoder preserving the consumer DTO type. - * @param request - Deferred typed openapi-effect request. - * @returns Effect containing schema-decoded response data. - * - * @pure false - performs browser HTTP IO and boundary decoding when the Effect is run. - * @effect openapi-effect request plus synchronous Effect Schema decoding. - * @invariant transport typing comes from OpenAPI; exported data typing comes from Schema. - * @precondition schema matches the endpoint success response documented in DockerGitApi. - * @postcondition no generated optional/default representation leaks into existing consumers. - * @complexity O(n) where n is the decoded response size. - * @throws Never; failures are returned in the Effect error channel. - */ -export const openApiJsonSchema = ( - client: DockerGitOpenApiTransportClient, - schema: Schema.Schema, - request: OpenApiRequest -): Effect.Effect => - openApiJsonSchemaWithRunner((nextRequest) => runOpenApi(client, nextRequest), schema, request) - -/** - * Executes a typed OpenAPI request whose successful response has no body. - * - * @param client - Typed docker-git OpenAPI transport client. - * @param request - Deferred typed openapi-effect request. - * @returns Effect that succeeds with void for successful empty responses. - * - * @pure false - performs browser HTTP IO when the Effect is run. - * @effect Network request via openapi-effect. - * @invariant only response status determines success for empty endpoints. - * @precondition request targets an endpoint whose OpenAPI success response has no content. - * @postcondition successful Effect returns void and never exposes transport details. - * @complexity O(n) local response rendering where n is the error payload size. - * @throws Never; failures are returned in the Effect error channel. - */ -export const openApiVoid = ( - client: DockerGitOpenApiTransportClient, - request: OpenApiRequest -): Effect.Effect => - openApiVoidWithRunner((nextRequest) => runOpenApi(client, nextRequest), request) - /** * Creates a reusable Effect OpenAPI client backed by a base URL resolver. * @@ -344,7 +258,6 @@ export const createClient = ( request(getOpenApiClient()) return { - openApiJson: (request) => openApiJsonWithRunner(runRuntimeOpenApi, request), openApiJsonSchema: (schema, request) => openApiJsonSchemaWithRunner(runRuntimeOpenApi, schema, request), openApiVoid: (request) => openApiVoidWithRunner(runRuntimeOpenApi, request) } From 10afed0b38c58954236b5fa1239a2102b63ce1c9 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:40:18 +0000 Subject: [PATCH 4/8] test(openapi): add explicit fast-check property --- .../docker-git/openapi-effect-client.test.ts | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/app/tests/docker-git/openapi-effect-client.test.ts b/packages/app/tests/docker-git/openapi-effect-client.test.ts index 535b24a7..0f08a3db 100644 --- a/packages/app/tests/docker-git/openapi-effect-client.test.ts +++ b/packages/app/tests/docker-git/openapi-effect-client.test.ts @@ -1,8 +1,9 @@ +import * as ParseResult from "@effect/schema/ParseResult" import * as Schema from "@effect/schema/Schema" import { describe, expect, it } from "@effect/vitest" import { createClient } from "@prover-coder-ai/docker-git-openapi" import type { ApiTransportValue } from "@prover-coder-ai/docker-git-openapi" -import { Effect } from "effect" +import { Effect, Either } from "effect" import * as fc from "fast-check" type CapturedRequest = { @@ -18,6 +19,8 @@ const HealthResponseSchema = Schema.Struct({ revision: Schema.NullOr(Schema.String) }) +const NullableStringTransportValue = fc.option(fc.string(), { nil: null }) + const createJsonResponse = (status: number, value: ApiTransportValue): Response => Response.json(value, { headers: { @@ -39,6 +42,25 @@ const createMockFetch = ( return Effect.runPromise(Effect.succeed(response)) } +/** + * Runs a fast-check synchronous property inside the Effect test runtime. + * + * @param property - Finite pure property over OpenAPI boundary values. + * @returns Effect that fails when fast-check finds a counterexample. + * + * @pure false - executes property samples. + * @effect Effect.sync, fc.assert. + * @invariant success proves every sampled case preserved the asserted pure invariant. + * @precondition property predicate is synchronous and total. + * @postcondition counterexamples are surfaced through the Effect error channel. + * @complexity O(r * c) where r is numRuns and c is one predicate cost. + * @throws Never. + */ +const assertOpenApiClientSyncProperty = (property: fc.IProperty) => + Effect.sync(() => { + fc.assert(property, { numRuns: 25 }) + }) + /** * Runs a fast-check async property inside the Effect test runtime. * @@ -91,6 +113,16 @@ describe("docker-git OpenAPI Effect client", () => { expect(new URL(requests[0]?.url ?? "").searchParams.has("_")).toBe(true) })) + it.effect("property: schema decoding preserves JSON null transport values", () => + assertOpenApiClientSyncProperty( + fc.property(NullableStringTransportValue, (value) => + Either.match(ParseResult.decodeUnknownEither(Schema.Null)(value), { + onLeft: () => + value !== null, + onRight: (decoded) => decoded === value + })) + )) + it.effect("property: GET requests always include no-cache transport invariants", () => assertOpenApiClientProperty( fc.asyncProperty( From 25cb31eafc1a14e201b6e15a70cf2b8d15ee5315 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:01:41 +0000 Subject: [PATCH 5/8] fix(openapi): narrow schema decode input --- packages/openapi/src/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/openapi/src/client.ts b/packages/openapi/src/client.ts index 735d925f..6d333859 100644 --- a/packages/openapi/src/client.ts +++ b/packages/openapi/src/client.ts @@ -196,7 +196,7 @@ const openApiJsonWithRunner = ( ) ) -const decodeSchema = (schema: Schema.Schema, value: unknown): Effect.Effect => +const decodeSchema = (schema: Schema.Schema, value: ApiTransportValue): Effect.Effect => Either.match(ParseResult.decodeUnknownEither(schema)(value), { onLeft: (error) => Effect.fail(TreeFormatter.formatIssueSync(error)), onRight: (decoded) => Effect.succeed(decoded) From 30c1e100f9f00592cfa8fe28d28cc68c3982f66c Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 19 Jun 2026 21:47:36 +0000 Subject: [PATCH 6/8] refactor(openapi): expose direct effect client --- bun.lock | 2 - packages/app/src/web/api-create-project.ts | 21 +- packages/app/src/web/api-database.ts | 120 +++---- packages/app/src/web/api-http.ts | 67 +++- packages/app/src/web/api-normalize.ts | 217 +++++++++++++ packages/app/src/web/api-project-core.ts | 110 ++++--- packages/app/src/web/api-prompts.ts | 44 +-- packages/app/src/web/api-share.ts | 33 +- packages/app/src/web/api-skills.ts | 44 +-- packages/app/src/web/api-tasks.ts | 50 ++- packages/app/src/web/api-terminal.ts | 144 ++++----- packages/app/src/web/api.ts | 195 ++++++------ .../app/tests/docker-git/api-terminal.test.ts | 39 +-- .../docker-git/openapi-effect-client.test.ts | 117 ++++--- packages/openapi/package.json | 4 +- packages/openapi/src/client.ts | 295 ++++-------------- 16 files changed, 773 insertions(+), 729 deletions(-) create mode 100644 packages/app/src/web/api-normalize.ts diff --git a/bun.lock b/bun.lock index 61b83688..ac3d6c76 100644 --- a/bun.lock +++ b/bun.lock @@ -236,9 +236,7 @@ "name": "@prover-coder-ai/docker-git-openapi", "version": "0.1.0", "dependencies": { - "@effect/schema": "^0.75.5", "@prover-coder-ai/openapi-effect": "^1.0.27", - "effect": "^3.21.3", }, "devDependencies": { "openapi-typescript": "^7.13.0", diff --git a/packages/app/src/web/api-create-project.ts b/packages/app/src/web/api-create-project.ts index d485644b..040e5c87 100644 --- a/packages/app/src/web/api-create-project.ts +++ b/packages/app/src/web/api-create-project.ts @@ -1,6 +1,6 @@ -import type { Effect } from "effect" +import { Effect, Match } from "effect" -import { dockerGitOpenApi } from "./api-http.js" +import { dockerGitOpenApi, renderDockerGitOpenApiFailure } from "./api-http.js" import { type BaseCreateProjectBody, baseCreateProjectBody, @@ -8,7 +8,6 @@ import { optionalProjectResourceFields, type OptionalProjectResourceFieldsBody } from "./api-project-create-body.js" -import { CreateProjectAcceptedResponseSchema } from "./api-schema.js" import type { CreateProjectAcceptedResponse } from "./api-schema.js" type CreateProjectAcceptedBody = Readonly< @@ -42,7 +41,15 @@ export const createProjectAcceptedBody = (draft: CreateProjectRequestDraft): Cre export const startCreateProject = ( draft: CreateProjectRequestDraft ): Effect.Effect => - dockerGitOpenApi.openApiJsonSchema(CreateProjectAcceptedResponseSchema, (client) => - client.POST("/projects", { - body: createProjectAcceptedBody(draft) - })) + dockerGitOpenApi.POST("/projects", { + body: createProjectAcceptedBody(draft) + }).pipe( + Effect.mapError(renderDockerGitOpenApiFailure), + Effect.flatMap((success) => + Match.value(success).pipe( + Match.when({ status: 202 }, ({ body }) => Effect.succeed(body)), + Match.when({ status: 201 }, () => Effect.fail("HTTP 201: unexpected synchronous project creation response")), + Match.exhaustive + ) + ) + ) diff --git a/packages/app/src/web/api-database.ts b/packages/app/src/web/api-database.ts index 2d046806..33c440c8 100644 --- a/packages/app/src/web/api-database.ts +++ b/packages/app/src/web/api-database.ts @@ -1,13 +1,6 @@ import { Effect } from "effect" -import { dockerGitOpenApi } from "./api-http.js" -import { - ProjectDatabaseForwardResponseSchema, - ProjectDatabaseForwardsResponseSchema, - ProjectDatabaseProfileResponseSchema, - ProjectDatabaseProfilesResponseSchema, - ProjectDatabaseSessionResponseSchema -} from "./api-schema.js" +import { dockerGitOpenApi, renderDockerGitOpenApiFailure } from "./api-http.js" import type { ProjectDatabaseForward, ProjectDatabaseSession } from "./api-schema.js" export const projectDatabaseEditorUrl = (session: ProjectDatabaseSession): string => session.editorPath @@ -16,25 +9,19 @@ export const projectDatabaseExternalUrl = (forward: ProjectDatabaseForward): str `${forward.publicHost}:${forward.hostPort}` export const loadProjectDatabaseProfiles = (projectId: string) => - dockerGitOpenApi.openApiJsonSchema( - ProjectDatabaseProfilesResponseSchema, - (client) => - client.GET("/projects/{projectId}/databases/profiles", { - params: { path: { projectId } } - }) - ).pipe( - Effect.map((response) => response.profiles) + dockerGitOpenApi.GET("/projects/{projectId}/databases/profiles", { + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => body.profiles), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const loadProjectDatabaseForwards = (projectId: string) => - dockerGitOpenApi.openApiJsonSchema( - ProjectDatabaseForwardsResponseSchema, - (client) => - client.GET("/projects/{projectId}/databases/forwards", { - params: { path: { projectId } } - }) - ).pipe( - Effect.map((response) => response.forwards) + dockerGitOpenApi.GET("/projects/{projectId}/databases/forwards", { + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => body.forwards), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const saveProjectDatabaseProfile = ( @@ -42,80 +29,67 @@ export const saveProjectDatabaseProfile = ( connectionString: string, label: string | null ) => - dockerGitOpenApi.openApiJsonSchema( - ProjectDatabaseProfileResponseSchema, - (client) => - client.POST("/projects/{projectId}/databases/profiles", { - body: { connectionString, label }, - params: { path: { projectId } } - }) - ).pipe( - Effect.map((response) => response.profile) + dockerGitOpenApi.POST("/projects/{projectId}/databases/profiles", { + body: { connectionString, label }, + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => body.profile), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const deleteProjectDatabaseProfile = ( projectId: string, profileId: string ) => - dockerGitOpenApi.openApiVoid((client) => - client.DELETE("/projects/{projectId}/databases/profiles/{profileId}", { - params: { path: { profileId, projectId } } - }) + dockerGitOpenApi.DELETE("/projects/{projectId}/databases/profiles/{profileId}", { + params: { path: { profileId, projectId } } + }).pipe( + Effect.asVoid, + Effect.mapError(renderDockerGitOpenApiFailure) ) export const exposeProjectDatabaseProfile = ( projectId: string, profileId: string ) => - dockerGitOpenApi.openApiJsonSchema( - ProjectDatabaseForwardResponseSchema, - (client) => - client.POST("/projects/{projectId}/databases/profiles/{profileId}/expose", { - params: { path: { profileId, projectId } } - }) - ).pipe( - Effect.map((response) => response.forward) + dockerGitOpenApi.POST("/projects/{projectId}/databases/profiles/{profileId}/expose", { + params: { path: { profileId, projectId } } + }).pipe( + Effect.map(({ body }) => body.forward), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const deleteProjectDatabaseForward = ( projectId: string, profileId: string ) => - dockerGitOpenApi.openApiVoid((client) => - client.DELETE("/projects/{projectId}/databases/profiles/{profileId}/expose", { - params: { path: { profileId, projectId } } - }) + dockerGitOpenApi.DELETE("/projects/{projectId}/databases/profiles/{profileId}/expose", { + params: { path: { profileId, projectId } } + }).pipe( + Effect.asVoid, + Effect.mapError(renderDockerGitOpenApiFailure) ) export const loadProjectDatabaseSession = (projectId: string) => - dockerGitOpenApi.openApiJsonSchema( - ProjectDatabaseSessionResponseSchema, - (client) => - client.GET("/projects/{projectId}/databases/session", { - params: { path: { projectId } } - }) - ).pipe( - Effect.map((response) => response.session) + dockerGitOpenApi.GET("/projects/{projectId}/databases/session", { + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => body.session), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const openProjectDatabaseEditor = (projectId: string) => - dockerGitOpenApi.openApiJsonSchema( - ProjectDatabaseSessionResponseSchema, - (client) => - client.POST("/projects/{projectId}/databases/open", { - params: { path: { projectId } } - }) - ).pipe( - Effect.map((response) => response.session) + dockerGitOpenApi.POST("/projects/{projectId}/databases/open", { + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => body.session), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const restartProjectDatabaseEditor = (projectId: string) => - dockerGitOpenApi.openApiJsonSchema( - ProjectDatabaseSessionResponseSchema, - (client) => - client.POST("/projects/{projectId}/databases/restart", { - params: { path: { projectId } } - }) - ).pipe( - Effect.map((response) => response.session) + dockerGitOpenApi.POST("/projects/{projectId}/databases/restart", { + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => body.session), + Effect.mapError(renderDockerGitOpenApiFailure) ) diff --git a/packages/app/src/web/api-http.ts b/packages/app/src/web/api-http.ts index c8472bdc..76600bb0 100644 --- a/packages/app/src/web/api-http.ts +++ b/packages/app/src/web/api-http.ts @@ -3,7 +3,8 @@ import * as ParseResult from "@effect/schema/ParseResult" import * as Schema from "@effect/schema/Schema" import * as TreeFormatter from "@effect/schema/TreeFormatter" import { createClient } from "@prover-coder-ai/docker-git-openapi" -import { Effect, Either } from "effect" +import type { BoundaryError } from "@prover-coder-ai/docker-git-openapi" +import { Effect, Either, Match } from "effect" import { type JsonRequest, parseResponseBody, renderJsonPayload } from "../docker-git/api-json.js" import { readHttpResponseTextStream } from "../shared/http-response-stream.js" @@ -19,6 +20,17 @@ type TextStreamRequest = { readonly path: string } +type RenderableOpenApiBody = boolean | number | object | string | null | undefined + +type RenderableOpenApiHttpError = { + readonly _tag: "HttpError" + readonly body: RenderableOpenApiBody + readonly contentType: string + readonly status: number | string +} + +export type RenderableOpenApiFailure = RenderableOpenApiHttpError | BoundaryError + const noCacheHeaders: Readonly> = { "cache-control": "no-cache, no-store, max-age=0", pragma: "no-cache" @@ -85,6 +97,57 @@ export const resolveApiBaseUrl = (): string => { : trimTrailingSlash(configured.trim()) } +const renderOpenApiBody = (body: RenderableOpenApiBody): string => { + if (body === undefined) { + return "empty response" + } + if (typeof body === "string") { + return body + } + return JSON.stringify(body, null, 2) +} + +// CHANGE: Convert direct openapi-effect failures to legacy UI string errors at the web boundary. +// WHY: app API functions still expose `Effect<_, string>` while transport typing is owned by openapi-effect. +// QUOTE(ТЗ): "client сам возвращает нужную схему рабочую" +// REF: user-openapi-effect-direct-client +// SOURCE: n/a +// FORMAT THEOREM: forall failure f: render(f) is total and does not alter success values. +// PURITY: SHELL +// EFFECT: none +// INVARIANT: only the error channel is collapsed to string for existing UI callers. +// COMPLEXITY: O(n)/O(n), where n is rendered body size. +/** + * Renders typed openapi-effect failures for existing UI string error channels. + * + * @param failure - Typed OpenAPI transport or HTTP failure. + * @returns User-facing diagnostic string. + * + * @pure true - deterministic formatting of immutable failure data. + * @effect none. + * @invariant success values are never inspected or changed; only failure values are rendered. + * @precondition failure was produced by the docker-git OpenAPI client. + * @postcondition return value is non-throwing and suitable for legacy `Effect<_, string>` callers. + * @complexity O(n)/O(n), where n is rendered response body size. + * @throws Never. + */ +export const renderDockerGitOpenApiFailure = (failure: RenderableOpenApiFailure): string => + Match.value(failure).pipe( + Match.when({ _tag: "HttpError" }, (error) => + String(error.status) === "429" + ? "HTTP 429: tunnel or proxy rate limited the request. Retry or request a fresh tunnel URL." + : renderOpenApiBody(error.body)), + Match.when({ _tag: "TransportError" }, (error) => error.error.message), + Match.when({ _tag: "UnexpectedStatus" }, (error) => `HTTP ${error.status}: ${error.body}`), + Match.when({ _tag: "UnexpectedContentType" }, (error) => + `HTTP ${error.status}: unexpected content type ${error.actual ?? "none"}: ${error.body}`), + Match.when({ _tag: "ParseError" }, (error) => + `HTTP ${error.status}: invalid ${error.contentType} response: ${error.error.message}`), + Match.when({ _tag: "DecodeError" }, (error) => + `HTTP ${error.status}: invalid decoded response: ${error.error.message}`), + Match.exhaustive + ) + /** * Configured docker-git OpenAPI client for the web HTTP boundary. * @@ -97,7 +160,7 @@ export const resolveApiBaseUrl = (): string => { * @throws Never. */ export const dockerGitOpenApi = createClient({ - resolveBaseUrl: resolveApiBaseUrl + baseUrl: resolveApiBaseUrl() }) export const requestText = ( diff --git a/packages/app/src/web/api-normalize.ts b/packages/app/src/web/api-normalize.ts new file mode 100644 index 00000000..6633f418 --- /dev/null +++ b/packages/app/src/web/api-normalize.ts @@ -0,0 +1,217 @@ +import type { + AuthSnapshot, + PanelCloudflareTunnelSession, + ProjectAuthSnapshot, + ProjectDetails, + ProjectSummary, + TerminalSession +} from "./api-schema.js" + +type OptionalProjectSummaryFields = { + readonly clonedOnHostname?: string | undefined + readonly containerName?: string | undefined +} + +type ProjectSummaryTransport = + & Omit + & OptionalProjectSummaryFields + +type ProjectDetailsTransport = + & Omit + & { + readonly clonedOnHostname?: string | undefined + } + +type OptionalAuthProviderSnapshotFields = { + readonly codexAuthEntries?: number | undefined + readonly codexAuthPath?: string | undefined + readonly grokAuthEntries?: number | undefined + readonly grokAuthPath?: string | undefined +} + +type AuthSnapshotTransport = + & Omit + & OptionalAuthProviderSnapshotFields + +type ProjectAuthSnapshotTransport = + & Omit + & OptionalAuthProviderSnapshotFields + +type OptionalTerminalSessionFields = { + readonly attachedClients?: number | undefined + readonly closedAt?: string | undefined + readonly exitCode?: number | undefined + readonly signal?: number | undefined + readonly startedAt?: string | undefined +} + +type TerminalSessionTransport = + & Omit + & OptionalTerminalSessionFields + +type PanelCloudflareTunnelSessionTransport = + & Omit + & { + readonly logTail: ReadonlyArray + } + +const normalizeAuthProviderSnapshotFields = ( + snapshot: Snapshot +) => ({ + ...snapshot, + codexAuthEntries: snapshot.codexAuthEntries ?? 0, + codexAuthPath: snapshot.codexAuthPath ?? "", + grokAuthEntries: snapshot.grokAuthEntries ?? 0, + grokAuthPath: snapshot.grokAuthPath ?? "" +}) + +/** + * Normalizes generated transport project summaries before exposing UI state. + * + * @param project - OpenAPI project summary transport shape. + * @returns UI project summary with exact optional fields. + * + * @pure true - deterministic object projection. + * @effect none. + * @invariant undefined optional fields are omitted and required project fields are preserved. + * @precondition project came from the typed OpenAPI client. + * @postcondition result satisfies ProjectSummary exact-optional semantics. + * @complexity O(1)/O(1). + * @throws Never. + */ +// CHANGE: Normalize generated transport project summaries before exposing UI state. +// WHY: OpenAPI optional fields are `T | undefined`; UI Schema types use exact optional properties. +// QUOTE(ТЗ): "client сам возвращает нужную схему рабочую" +// REF: user-openapi-effect-direct-client +// SOURCE: n/a +// FORMAT THEOREM: forall p: undefined optional fields are omitted in normalizeProjectSummary(p). +// PURITY: CORE +// EFFECT: none +// INVARIANT: required project fields are preserved exactly. +// COMPLEXITY: O(1)/O(1) +export const normalizeProjectSummary = (project: ProjectSummaryTransport): ProjectSummary => { + const { clonedOnHostname, containerName, ...required } = project + return { + ...required, + ...(clonedOnHostname !== undefined && { clonedOnHostname }), + ...(containerName !== undefined && { containerName }) + } +} + +/** + * Normalizes generated project details before exposing UI state. + * + * @param project - OpenAPI project details transport shape. + * @returns UI project details with exact optional fields. + * + * @pure true. + * @effect none. + * @invariant clonedOnHostname is omitted when absent; all required details are preserved. + * @precondition project came from a typed project details endpoint. + * @postcondition result satisfies ProjectDetails exact-optional semantics. + * @complexity O(1)/O(1). + * @throws Never. + */ +export const normalizeProjectDetails = (project: ProjectDetailsTransport): ProjectDetails => { + const { clonedOnHostname, ...required } = project + return clonedOnHostname === undefined ? required : { ...required, clonedOnHostname } +} + +/** + * Normalizes auth snapshot provider defaults. + * + * @param snapshot - OpenAPI global auth snapshot. + * @returns UI auth snapshot with codex/grok defaults filled. + * + * @pure true. + * @effect none. + * @invariant missing codex/grok counts become 0 and missing paths become empty strings. + * @precondition snapshot came from the auth menu endpoint. + * @postcondition result satisfies AuthSnapshot required-field semantics. + * @complexity O(1)/O(1). + * @throws Never. + */ +export const normalizeAuthSnapshot = (snapshot: AuthSnapshotTransport): AuthSnapshot => + normalizeAuthProviderSnapshotFields(snapshot) + +/** + * Normalizes project auth snapshot provider defaults. + * + * @param snapshot - OpenAPI project auth snapshot. + * @returns UI project auth snapshot with codex/grok defaults filled. + * + * @pure true. + * @effect none. + * @invariant missing codex/grok counts become 0 and missing paths become empty strings. + * @precondition snapshot came from the project auth menu endpoint. + * @postcondition result satisfies ProjectAuthSnapshot required-field semantics. + * @complexity O(1)/O(1). + * @throws Never. + */ +export const normalizeProjectAuthSnapshot = (snapshot: ProjectAuthSnapshotTransport): ProjectAuthSnapshot => + normalizeAuthProviderSnapshotFields(snapshot) + +/** + * Normalizes terminal session optional fields. + * + * @param session - OpenAPI terminal session transport shape. + * @returns UI terminal session with exact optional fields. + * + * @pure true. + * @effect none. + * @invariant undefined optional terminal fields are omitted from the result. + * @precondition session came from a typed terminal endpoint. + * @postcondition result satisfies TerminalSession exact-optional semantics. + * @complexity O(1)/O(1). + * @throws Never. + */ +export const normalizeTerminalSession = (session: TerminalSessionTransport): TerminalSession => { + const { attachedClients, closedAt, exitCode, signal, startedAt, ...required } = session + return { + ...required, + ...(attachedClients !== undefined && { attachedClients }), + ...(closedAt !== undefined && { closedAt }), + ...(exitCode !== undefined && { exitCode }), + ...(signal !== undefined && { signal }), + ...(startedAt !== undefined && { startedAt }) + } +} + +/** + * Normalizes panel Cloudflare tunnel session array mutability. + * + * @param session - OpenAPI panel tunnel session transport shape. + * @returns UI panel tunnel session with a local logTail array copy. + * + * @pure true. + * @effect none. + * @invariant scalar tunnel fields are preserved and logTail values keep order. + * @precondition session came from a typed panel tunnel endpoint. + * @postcondition result satisfies PanelCloudflareTunnelSession array semantics. + * @complexity O(n)/O(n), where n is logTail length. + * @throws Never. + */ +export const normalizePanelCloudflareTunnelSession = ( + session: PanelCloudflareTunnelSessionTransport +): PanelCloudflareTunnelSession => ({ + ...session, + logTail: [...session.logTail] +}) + +/** + * Normalizes nullable panel Cloudflare tunnel sessions. + * + * @param session - OpenAPI panel tunnel session or null. + * @returns null unchanged or a normalized UI panel tunnel session. + * + * @pure true. + * @effect none. + * @invariant null remains null; non-null sessions are normalized by normalizePanelCloudflareTunnelSession. + * @precondition value came from a typed panel tunnel endpoint. + * @postcondition result is safe for UI panel tunnel state. + * @complexity O(n)/O(n), where n is logTail length for non-null sessions. + * @throws Never. + */ +export const normalizeNullablePanelCloudflareTunnelSession = ( + session: PanelCloudflareTunnelSessionTransport | null +): PanelCloudflareTunnelSession | null => session === null ? null : normalizePanelCloudflareTunnelSession(session) diff --git a/packages/app/src/web/api-project-core.ts b/packages/app/src/web/api-project-core.ts index 5e180447..e819df72 100644 --- a/packages/app/src/web/api-project-core.ts +++ b/packages/app/src/web/api-project-core.ts @@ -1,13 +1,13 @@ -import { Effect } from "effect" +import { Effect, Match } from "effect" import type { ApplyProjectRequest } from "../shared/project-resource-request.js" -import { dockerGitOpenApi } from "./api-http.js" +import { dockerGitOpenApi, renderDockerGitOpenApiFailure } from "./api-http.js" +import { normalizeProjectDetails } from "./api-normalize.js" import { baseCreateProjectBody, type CreateProjectRequestDraft, optionalProjectResourceFields } from "./api-project-create-body.js" -import { OutputResponseSchema, ProjectResponseSchema } from "./api-schema.js" export type { ApplyProjectRequest, ProjectResourceLimitRequest } from "../shared/project-resource-request.js" export type { CreateProjectRequestDraft } from "./api-project-create-body.js" @@ -25,70 +25,76 @@ const createProjectBody = (draft: CreateProjectRequestDraft) => ({ }) export const loadProjectDetails = (projectId: string) => - dockerGitOpenApi.openApiJsonSchema(ProjectResponseSchema, (client) => - client.GET("/projects/{projectId}", { - params: { path: { projectId } } - })).pipe( - Effect.map((response) => response.project) - ) + dockerGitOpenApi.GET("/projects/{projectId}", { + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => normalizeProjectDetails(body.project)), + Effect.mapError(renderDockerGitOpenApiFailure) + ) export const loadProjectPs = (projectId: string) => - dockerGitOpenApi.openApiJsonSchema(OutputResponseSchema, (client) => - client.GET("/projects/{projectId}/ps", { - params: { path: { projectId } } - })).pipe( - Effect.map((response) => response.output) - ) + dockerGitOpenApi.GET("/projects/{projectId}/ps", { + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => body.output), + Effect.mapError(renderDockerGitOpenApiFailure) + ) export const loadProjectLogs = (projectId: string) => - dockerGitOpenApi.openApiJsonSchema(OutputResponseSchema, (client) => - client.GET("/projects/{projectId}/logs", { - params: { path: { projectId } } - })).pipe( - Effect.map((response) => response.output) - ) + dockerGitOpenApi.GET("/projects/{projectId}/logs", { + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => body.output), + Effect.mapError(renderDockerGitOpenApiFailure) + ) export const applyProject = ( projectId: string, request?: ApplyProjectRequest ) => - dockerGitOpenApi.openApiJsonSchema(ProjectResponseSchema, (client) => - client.POST("/projects/{projectId}/apply", { - body: applyProjectBody(request), - params: { path: { projectId } } - })).pipe( - Effect.map((response) => response.project) - ) + dockerGitOpenApi.POST("/projects/{projectId}/apply", { + body: applyProjectBody(request), + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => normalizeProjectDetails(body.project)), + Effect.mapError(renderDockerGitOpenApiFailure) + ) export const createProject = (draft: CreateProjectRequestDraft) => - dockerGitOpenApi.openApiJsonSchema(ProjectResponseSchema, (client) => - client.POST("/projects", { - body: createProjectBody(draft) - })).pipe( - Effect.map((response) => response.project) + dockerGitOpenApi.POST("/projects", { + body: createProjectBody(draft) + }).pipe( + Effect.mapError(renderDockerGitOpenApiFailure), + Effect.flatMap((success) => + Match.value(success).pipe( + Match.when({ status: 201 }, ({ body }) => Effect.succeed(normalizeProjectDetails(body.project))), + Match.when({ status: 202 }, () => Effect.fail("HTTP 202: unexpected async project creation response")), + Match.exhaustive + ) ) + ) export const upProject = (projectId: string) => - dockerGitOpenApi.openApiJsonSchema(ProjectResponseSchema, (client) => - client.POST("/projects/{projectId}/up", { - body: { useManagedAuthorizedKeys: true }, - params: { path: { projectId } } - })).pipe( - Effect.map((response) => response.project) - ) + dockerGitOpenApi.POST("/projects/{projectId}/up", { + body: { useManagedAuthorizedKeys: true }, + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => normalizeProjectDetails(body.project)), + Effect.mapError(renderDockerGitOpenApiFailure) + ) export const resumeProject = (projectId: string) => - dockerGitOpenApi.openApiJsonSchema(ProjectResponseSchema, (client) => - client.POST("/projects/{projectId}/resume", { - params: { path: { projectId } } - })).pipe( - Effect.map((response) => response.project) - ) + dockerGitOpenApi.POST("/projects/{projectId}/resume", { + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => normalizeProjectDetails(body.project)), + Effect.mapError(renderDockerGitOpenApiFailure) + ) export const suspendProject = (projectId: string) => - dockerGitOpenApi.openApiJsonSchema(ProjectResponseSchema, (client) => - client.POST("/projects/{projectId}/suspend", { - params: { path: { projectId } } - })).pipe( - Effect.map((response) => response.project) - ) + dockerGitOpenApi.POST("/projects/{projectId}/suspend", { + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => normalizeProjectDetails(body.project)), + Effect.mapError(renderDockerGitOpenApiFailure) + ) diff --git a/packages/app/src/web/api-prompts.ts b/packages/app/src/web/api-prompts.ts index 660a07e5..77b7ed61 100644 --- a/packages/app/src/web/api-prompts.ts +++ b/packages/app/src/web/api-prompts.ts @@ -1,18 +1,14 @@ import { Effect } from "effect" -import { dockerGitOpenApi } from "./api-http.js" -import { ProjectPromptsResponseSchema, ProjectPromptUpdateResponseSchema } from "./api-schema.js" +import { dockerGitOpenApi, renderDockerGitOpenApiFailure } from "./api-http.js" import type { ProjectPromptKind } from "./api-schema.js" export const loadProjectPrompts = (projectId: string) => - dockerGitOpenApi.openApiJsonSchema( - ProjectPromptsResponseSchema, - (client) => - client.GET("/projects/{projectId}/prompts", { - params: { path: { projectId } } - }) - ).pipe( - Effect.map((response) => response.snapshot) + dockerGitOpenApi.GET("/projects/{projectId}/prompts", { + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => body.snapshot), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const writeProjectPrompt = ( @@ -20,27 +16,21 @@ export const writeProjectPrompt = ( kind: ProjectPromptKind, content: string ) => - dockerGitOpenApi.openApiJsonSchema( - ProjectPromptUpdateResponseSchema, - (client) => - client.PUT("/projects/{projectId}/prompts/{kind}", { - body: { content }, - params: { path: { kind, projectId } } - }) - ).pipe( - Effect.map((response) => response.snapshot) + dockerGitOpenApi.PUT("/projects/{projectId}/prompts/{kind}", { + body: { content }, + params: { path: { kind, projectId } } + }).pipe( + Effect.map(({ body }) => body.snapshot), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const deleteProjectPrompt = ( projectId: string, kind: ProjectPromptKind ) => - dockerGitOpenApi.openApiJsonSchema( - ProjectPromptsResponseSchema, - (client) => - client.DELETE("/projects/{projectId}/prompts/{kind}", { - params: { path: { kind, projectId } } - }) - ).pipe( - Effect.map((response) => response.snapshot) + dockerGitOpenApi.DELETE("/projects/{projectId}/prompts/{kind}", { + params: { path: { kind, projectId } } + }).pipe( + Effect.map(({ body }) => body.snapshot), + Effect.mapError(renderDockerGitOpenApiFailure) ) diff --git a/packages/app/src/web/api-share.ts b/packages/app/src/web/api-share.ts index 55c84901..0ec2d703 100644 --- a/packages/app/src/web/api-share.ts +++ b/packages/app/src/web/api-share.ts @@ -1,7 +1,7 @@ import { Effect } from "effect" -import { dockerGitOpenApi } from "./api-http.js" -import { PanelCloudflareTunnelResponseSchema } from "./api-schema.js" +import { dockerGitOpenApi, renderDockerGitOpenApiFailure } from "./api-http.js" +import { normalizeNullablePanelCloudflareTunnelSession } from "./api-normalize.js" /** * Reads the controller-owned panel Cloudflare tunnel session. @@ -25,11 +25,9 @@ import { PanelCloudflareTunnelResponseSchema } from "./api-schema.js" // INVARIANT: Only schema-decoded tunnel state crosses the API boundary. // COMPLEXITY: O(1) local work plus network IO. export const loadPanelCloudflareTunnel = () => - dockerGitOpenApi.openApiJsonSchema( - PanelCloudflareTunnelResponseSchema, - (client) => client.GET("/cloudflare-tunnels/panel") - ).pipe( - Effect.map((response) => response.tunnel) + dockerGitOpenApi.GET("/cloudflare-tunnels/panel").pipe( + Effect.map(({ body }) => normalizeNullablePanelCloudflareTunnelSession(body.tunnel)), + Effect.mapError(renderDockerGitOpenApiFailure) ) /** @@ -54,14 +52,11 @@ export const loadPanelCloudflareTunnel = () => // INVARIANT: Returned state is decoded by PanelCloudflareTunnelResponseSchema. // COMPLEXITY: O(1) local work plus network IO and controller-side startup. export const startPanelCloudflareTunnel = (panelUrl: string) => - dockerGitOpenApi.openApiJsonSchema( - PanelCloudflareTunnelResponseSchema, - (client) => - client.POST("/cloudflare-tunnels/panel", { - body: { panelUrl } - }) - ).pipe( - Effect.map((response) => response.tunnel) + dockerGitOpenApi.POST("/cloudflare-tunnels/panel", { + body: { panelUrl } + }).pipe( + Effect.map(({ body }) => normalizeNullablePanelCloudflareTunnelSession(body.tunnel)), + Effect.mapError(renderDockerGitOpenApiFailure) ) /** @@ -86,9 +81,7 @@ export const startPanelCloudflareTunnel = (panelUrl: string) => // INVARIANT: Returned state is decoded by PanelCloudflareTunnelResponseSchema. // COMPLEXITY: O(1) local work plus network IO and controller-side cleanup. export const stopPanelCloudflareTunnel = () => - dockerGitOpenApi.openApiJsonSchema( - PanelCloudflareTunnelResponseSchema, - (client) => client.DELETE("/cloudflare-tunnels/panel") - ).pipe( - Effect.map((response) => response.tunnel) + dockerGitOpenApi.DELETE("/cloudflare-tunnels/panel").pipe( + Effect.map(({ body }) => normalizeNullablePanelCloudflareTunnelSession(body.tunnel)), + Effect.mapError(renderDockerGitOpenApiFailure) ) diff --git a/packages/app/src/web/api-skills.ts b/packages/app/src/web/api-skills.ts index fd6ca1b2..d7e6c17a 100644 --- a/packages/app/src/web/api-skills.ts +++ b/packages/app/src/web/api-skills.ts @@ -1,7 +1,6 @@ import { Effect } from "effect" -import { dockerGitOpenApi } from "./api-http.js" -import { ProjectSkillsResponseSchema, ProjectSkillUpdateResponseSchema } from "./api-schema.js" +import { dockerGitOpenApi, renderDockerGitOpenApiFailure } from "./api-http.js" import type { ProjectSkillScope } from "./api-schema.js" const skillScopeIdByScope: Readonly> = { @@ -17,14 +16,11 @@ const skillScopeIdByScope: Readonly> = { export const projectSkillScopeToId = (scope: ProjectSkillScope): string => skillScopeIdByScope[scope] export const loadProjectSkills = (projectId: string) => - dockerGitOpenApi.openApiJsonSchema( - ProjectSkillsResponseSchema, - (client) => - client.GET("/projects/{projectId}/skills", { - params: { path: { projectId } } - }) - ).pipe( - Effect.map((response) => response.snapshot) + dockerGitOpenApi.GET("/projects/{projectId}/skills", { + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => body.snapshot), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const writeProjectSkill = ( @@ -33,15 +29,12 @@ export const writeProjectSkill = ( name: string, content: string ) => - dockerGitOpenApi.openApiJsonSchema( - ProjectSkillUpdateResponseSchema, - (client) => - client.POST("/projects/{projectId}/skills", { - body: { content, name, scope }, - params: { path: { projectId } } - }) - ).pipe( - Effect.map((response) => response.snapshot) + dockerGitOpenApi.POST("/projects/{projectId}/skills", { + body: { content, name, scope }, + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => body.snapshot), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const deleteProjectSkill = ( @@ -49,12 +42,9 @@ export const deleteProjectSkill = ( scope: ProjectSkillScope, name: string ) => - dockerGitOpenApi.openApiJsonSchema( - ProjectSkillsResponseSchema, - (client) => - client.DELETE("/projects/{projectId}/skills/{scopeId}/{name}", { - params: { path: { name, projectId, scopeId: projectSkillScopeToId(scope) } } - }) - ).pipe( - Effect.map((response) => response.snapshot) + dockerGitOpenApi.DELETE("/projects/{projectId}/skills/{scopeId}/{name}", { + params: { path: { name, projectId, scopeId: projectSkillScopeToId(scope) } } + }).pipe( + Effect.map(({ body }) => body.snapshot), + Effect.mapError(renderDockerGitOpenApiFailure) ) diff --git a/packages/app/src/web/api-tasks.ts b/packages/app/src/web/api-tasks.ts index eff20648..e796efec 100644 --- a/packages/app/src/web/api-tasks.ts +++ b/packages/app/src/web/api-tasks.ts @@ -1,30 +1,27 @@ import { Effect } from "effect" -import { dockerGitOpenApi } from "./api-http.js" -import { ContainerTaskSnapshotResponseSchema, OutputResponseSchema } from "./api-schema.js" +import { dockerGitOpenApi, renderDockerGitOpenApiFailure } from "./api-http.js" export const loadProjectTasks = (projectId: string, shouldIncludeDefault = false) => - dockerGitOpenApi.openApiJsonSchema( - ContainerTaskSnapshotResponseSchema, - (client) => - client.GET("/projects/{projectId}/tasks", { - params: { - path: { projectId }, - query: shouldIncludeDefault ? { includeDefault: "true" } : {} - } - }) - ).pipe( - Effect.map((response) => response.snapshot) + dockerGitOpenApi.GET("/projects/{projectId}/tasks", { + params: { + path: { projectId }, + query: shouldIncludeDefault ? { includeDefault: "true" } : {} + } + }).pipe( + Effect.map(({ body }) => body.snapshot), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const stopProjectTask = ( projectId: string, pid: number ) => - dockerGitOpenApi.openApiVoid((client) => - client.POST("/projects/{projectId}/tasks/{pid}/stop", { - params: { path: { pid: String(pid), projectId } } - }) + dockerGitOpenApi.POST("/projects/{projectId}/tasks/{pid}/stop", { + params: { path: { pid: String(pid), projectId } } + }).pipe( + Effect.asVoid, + Effect.mapError(renderDockerGitOpenApiFailure) ) export const loadProjectTaskLogs = ( @@ -32,15 +29,12 @@ export const loadProjectTaskLogs = ( pid: number, lines = 200 ) => - dockerGitOpenApi.openApiJsonSchema( - OutputResponseSchema, - (client) => - client.GET("/projects/{projectId}/tasks/{pid}/logs", { - params: { - path: { pid: String(pid), projectId }, - query: { lines: String(lines) } - } - }) - ).pipe( - Effect.map((response) => response.output) + dockerGitOpenApi.GET("/projects/{projectId}/tasks/{pid}/logs", { + params: { + path: { pid: String(pid), projectId }, + query: { lines: String(lines) } + } + }).pipe( + Effect.map(({ body }) => body.output), + Effect.mapError(renderDockerGitOpenApiFailure) ) diff --git a/packages/app/src/web/api-terminal.ts b/packages/app/src/web/api-terminal.ts index 8c1e0b1b..08e3ea86 100644 --- a/packages/app/src/web/api-terminal.ts +++ b/packages/app/src/web/api-terminal.ts @@ -1,133 +1,117 @@ import { Effect } from "effect" -import { dockerGitOpenApi } from "./api-http.js" -import { - AuthTerminalSessionResponseSchema, - ProjectTerminalSessionResponseSchema, - ProjectTerminalSessionsResponseSchema, - StartProjectTerminalSessionAcceptedResponseSchema, - TerminalSessionLookupResponseSchema, - TerminalSessionResponseSchema -} from "./api-schema.js" +import { dockerGitOpenApi, renderDockerGitOpenApiFailure } from "./api-http.js" +import { normalizeProjectDetails, normalizeTerminalSession } from "./api-normalize.js" export const createProjectTerminalSession = (projectKey: string) => - dockerGitOpenApi.openApiJsonSchema( - TerminalSessionResponseSchema, - (client) => - client.POST("/projects/by-key/{projectKey}/terminal-sessions", { - params: { path: { projectKey } } - }) - ).pipe( - Effect.map((response) => ({ - project: response.project, - session: response.session - })) + dockerGitOpenApi.POST("/projects/by-key/{projectKey}/terminal-sessions", { + params: { path: { projectKey } } + }).pipe( + Effect.map(({ body }) => ({ + project: normalizeProjectDetails(body.project), + session: normalizeTerminalSession(body.session) + })), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const startProjectTerminalSession = ( projectKey: string, requestId: string ) => - dockerGitOpenApi.openApiJsonSchema( - StartProjectTerminalSessionAcceptedResponseSchema, - (client) => - client.POST("/projects/by-key/{projectKey}/terminal-sessions/start", { - body: { requestId }, - params: { path: { projectKey } } - }) + dockerGitOpenApi.POST("/projects/by-key/{projectKey}/terminal-sessions/start", { + body: { requestId }, + params: { path: { projectKey } } + }).pipe( + Effect.map(({ body }) => body), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const createAuthTerminalSession = ( flow: "ClaudeOauth" | "GeminiOauth" | "GrokOauth", label: string | null ) => - dockerGitOpenApi.openApiJsonSchema( - AuthTerminalSessionResponseSchema, - (client) => - client.POST("/auth/terminal-sessions", { - body: { flow, label } - }) - ).pipe( - Effect.map((response) => response.session) + dockerGitOpenApi.POST("/auth/terminal-sessions", { + body: { flow, label } + }).pipe( + Effect.map(({ body }) => normalizeTerminalSession(body.session)), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const deleteProjectTerminalSession = ( projectKey: string, sessionId: string ) => - dockerGitOpenApi.openApiVoid((client) => - client.DELETE("/projects/by-key/{projectKey}/terminal-sessions/{sessionId}", { - params: { path: { projectKey, sessionId } } - }) + dockerGitOpenApi.DELETE("/projects/by-key/{projectKey}/terminal-sessions/{sessionId}", { + params: { path: { projectKey, sessionId } } + }).pipe( + Effect.asVoid, + Effect.mapError(renderDockerGitOpenApiFailure) ) export const deleteAuthTerminalSession = (sessionId: string) => - dockerGitOpenApi.openApiVoid((client) => - client.DELETE("/auth/terminal-sessions/{sessionId}", { - params: { path: { sessionId } } - }) + dockerGitOpenApi.DELETE("/auth/terminal-sessions/{sessionId}", { + params: { path: { sessionId } } + }).pipe( + Effect.asVoid, + Effect.mapError(renderDockerGitOpenApiFailure) ) // WHY: panel UI needs only the sessions array for list rendering. // INVARIANT: this helper intentionally projects the full terminal workspace response to sessions. export const loadProjectTerminalSessions = (projectKey: string) => - dockerGitOpenApi.openApiJsonSchema( - ProjectTerminalSessionsResponseSchema, - (client) => - client.GET("/projects/by-key/{projectKey}/terminal-sessions", { - params: { path: { projectKey } } - }) - ).pipe( - Effect.map((response) => response.sessions) + dockerGitOpenApi.GET("/projects/by-key/{projectKey}/terminal-sessions", { + params: { path: { projectKey } } + }).pipe( + Effect.map(({ body }) => body.sessions.map((session) => normalizeTerminalSession(session))), + Effect.mapError(renderDockerGitOpenApiFailure) ) // WHY: SSH-link initialization needs the full terminal workspace, including activeSessionId. // INVARIANT: this helper intentionally preserves the complete response shape. export const loadProjectTerminalWorkspace = (projectKey: string) => - dockerGitOpenApi.openApiJsonSchema( - ProjectTerminalSessionsResponseSchema, - (client) => - client.GET("/projects/by-key/{projectKey}/terminal-sessions", { - params: { path: { projectKey } } - }) + dockerGitOpenApi.GET("/projects/by-key/{projectKey}/terminal-sessions", { + params: { path: { projectKey } } + }).pipe( + Effect.map(({ body }) => ({ + activeSessionId: body.activeSessionId, + sessions: body.sessions.map((session) => normalizeTerminalSession(session)) + })), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const setProjectActiveTerminalSession = ( projectKey: string, sessionId: string ) => - dockerGitOpenApi.openApiJsonSchema( - ProjectTerminalSessionResponseSchema, - (client) => - client.PUT("/projects/by-key/{projectKey}/terminal-sessions/active", { - body: { sessionId }, - params: { path: { projectKey } } - }) - ).pipe( - Effect.map((response) => response.session) + dockerGitOpenApi.PUT("/projects/by-key/{projectKey}/terminal-sessions/active", { + body: { sessionId }, + params: { path: { projectKey } } + }).pipe( + Effect.map(({ body }) => normalizeTerminalSession(body.session)), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const loadProjectTerminalSession = ( projectKey: string, sessionId: string ) => - dockerGitOpenApi.openApiJsonSchema( - ProjectTerminalSessionResponseSchema, - (client) => - client.GET("/projects/by-key/{projectKey}/terminal-sessions/{sessionId}", { - params: { path: { projectKey, sessionId } } - }) - ).pipe( - Effect.map((response) => response.session) + dockerGitOpenApi.GET("/projects/by-key/{projectKey}/terminal-sessions/{sessionId}", { + params: { path: { projectKey, sessionId } } + }).pipe( + Effect.map(({ body }) => normalizeTerminalSession(body.session)), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const loadTerminalSessionById = (sessionId: string) => - dockerGitOpenApi.openApiJsonSchema( - TerminalSessionLookupResponseSchema, - (client) => - client.GET("/terminal-sessions/{sessionId}", { - params: { path: { sessionId } } - }) + dockerGitOpenApi.GET("/terminal-sessions/{sessionId}", { + params: { path: { sessionId } } + }).pipe( + Effect.map(({ body }) => ({ + projectDisplayName: body.projectDisplayName, + projectKey: body.projectKey, + session: normalizeTerminalSession(body.session) + })), + Effect.mapError(renderDockerGitOpenApiFailure) ) const invalidTerminalClosePath = (path: string): string => `Invalid terminal close path: ${path}` diff --git a/packages/app/src/web/api.ts b/packages/app/src/web/api.ts index 44eec6e6..8ddb48f1 100644 --- a/packages/app/src/web/api.ts +++ b/packages/app/src/web/api.ts @@ -3,20 +3,15 @@ import { Effect } from "effect" import { sortSelectItemsByLaunchTime } from "../docker-git/menu-select-order.js" import type { SelectProjectRuntime } from "../docker-git/menu-types.js" import type { AuthMenuRequestBody, ProjectAuthMenuRequestBody } from "../shared/auth-menu-request.js" -import { dockerGitOpenApi, requestJson, requestTextStream, resolveApiBaseUrl } from "./api-http.js" import { - AuthSnapshotResponseSchema, - CodexStatusResponseSchema, - GithubStatusResponseSchema, - HealthResponseSchema, - ProjectAuthSnapshotResponseSchema, - ProjectBrowserResponseSchema, - ProjectEventsPollResponseSchema, - ProjectPortForwardResponseSchema, - ProjectPortForwardsResponseSchema, - ProjectsResponseSchema, - SkillerLaunchResponseSchema -} from "./api-schema.js" + dockerGitOpenApi, + renderDockerGitOpenApiFailure, + requestJson, + requestTextStream, + resolveApiBaseUrl +} from "./api-http.js" +import { normalizeAuthSnapshot, normalizeProjectAuthSnapshot, normalizeProjectSummary } from "./api-normalize.js" +import { ProjectEventsPollResponseSchema, SkillerLaunchResponseSchema } from "./api-schema.js" import type { AuthMenuFlow, DashboardData, @@ -113,13 +108,19 @@ export const sortDashboardProjects = ( export const loadDashboard = (): Effect.Effect => Effect.all({ - health: dockerGitOpenApi.openApiJsonSchema(HealthResponseSchema, (client) => client.GET("/health")), - projectsResponse: dockerGitOpenApi.openApiJsonSchema(ProjectsResponseSchema, (client) => client.GET("/projects")) + health: dockerGitOpenApi.GET("/health").pipe( + Effect.map(({ body }) => body), + Effect.mapError(renderDockerGitOpenApiFailure) + ), + projectsResponse: dockerGitOpenApi.GET("/projects").pipe( + Effect.map(({ body }) => body), + Effect.mapError(renderDockerGitOpenApiFailure) + ) }).pipe( Effect.map(({ health, projectsResponse }) => ({ apiBaseUrl: resolveApiBaseUrl(), health, - projects: sortDashboardProjects(projectsResponse.projects) + projects: sortDashboardProjects(projectsResponse.projects.map((project) => normalizeProjectSummary(project))) })) ) @@ -142,97 +143,96 @@ export const openSkiller = (projectKey?: string, sessionId?: string) => ) export const loadProjectPortForwards = (projectId: string) => - dockerGitOpenApi.openApiJsonSchema( - ProjectPortForwardsResponseSchema, - (client) => - client.GET("/projects/{projectId}/ports", { - params: { path: { projectId } } - }) - ).pipe( - Effect.map((response) => response.forwards) + dockerGitOpenApi.GET("/projects/{projectId}/ports", { + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => body.forwards), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const loadProjectBrowser = (projectId: string) => - dockerGitOpenApi.openApiJsonSchema( - ProjectBrowserResponseSchema, - (client) => - client.GET("/projects/{projectId}/browser", { - params: { path: { projectId } } - }) + dockerGitOpenApi.GET("/projects/{projectId}/browser", { + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => body.browser), + Effect.mapError(renderDockerGitOpenApiFailure) ) - .pipe(Effect.map((response) => response.browser)) export const startProjectBrowser = (projectId: string) => - dockerGitOpenApi.openApiJsonSchema( - ProjectBrowserResponseSchema, - (client) => - client.POST("/projects/{projectId}/browser/start", { - params: { path: { projectId } } - }) + dockerGitOpenApi.POST("/projects/{projectId}/browser/start", { + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => body.browser), + Effect.mapError(renderDockerGitOpenApiFailure) ) - .pipe(Effect.map((response) => response.browser)) export const createProjectPortForward = ( projectId: string, targetPort: number, hostPort?: number ) => - dockerGitOpenApi.openApiJsonSchema( - ProjectPortForwardResponseSchema, - (client) => - client.POST("/projects/{projectId}/ports", { - body: hostPort === undefined ? { targetPort } : { hostPort, targetPort }, - params: { path: { projectId } } - }) - ).pipe( - Effect.map((response) => response.forward) + dockerGitOpenApi.POST("/projects/{projectId}/ports", { + body: hostPort === undefined ? { targetPort } : { hostPort, targetPort }, + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => body.forward), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const deleteProjectPortForward = ( projectId: string, targetPort: number ) => - dockerGitOpenApi.openApiVoid((client) => - client.DELETE("/projects/{projectId}/ports/{targetPort}", { - params: { path: { projectId, targetPort: String(targetPort) } } - }) + dockerGitOpenApi.DELETE("/projects/{projectId}/ports/{targetPort}", { + params: { path: { projectId, targetPort: String(targetPort) } } + }).pipe( + Effect.asVoid, + Effect.mapError(renderDockerGitOpenApiFailure) ) export const downProject = (projectId: string) => - dockerGitOpenApi.openApiVoid((client) => - client.POST("/projects/{projectId}/down", { - params: { path: { projectId } } - }) + dockerGitOpenApi.POST("/projects/{projectId}/down", { + params: { path: { projectId } } + }).pipe( + Effect.asVoid, + Effect.mapError(renderDockerGitOpenApiFailure) ) export const deleteProject = (projectId: string) => - dockerGitOpenApi.openApiVoid((client) => - client.DELETE("/projects/{projectId}", { - params: { path: { projectId } } - }) + dockerGitOpenApi.DELETE("/projects/{projectId}", { + params: { path: { projectId } } + }).pipe( + Effect.asVoid, + Effect.mapError(renderDockerGitOpenApiFailure) ) -export const downAllProjects = () => dockerGitOpenApi.openApiVoid((client) => client.POST("/projects/down-all")) +export const downAllProjects = () => + dockerGitOpenApi.POST("/projects/down-all").pipe( + Effect.asVoid, + Effect.mapError(renderDockerGitOpenApiFailure) + ) export const applyAllProjects = (shouldApplyActiveOnly: boolean) => - dockerGitOpenApi.openApiVoid((client) => - client.POST("/projects/apply-all", { - body: { activeOnly: shouldApplyActiveOnly } - }) + dockerGitOpenApi.POST("/projects/apply-all", { + body: { activeOnly: shouldApplyActiveOnly } + }).pipe( + Effect.asVoid, + Effect.mapError(renderDockerGitOpenApiFailure) ) export const loadGithubStatus = () => - dockerGitOpenApi.openApiJsonSchema(GithubStatusResponseSchema, (client) => client.GET("/auth/github/status")).pipe( - Effect.map((response) => response.status) + dockerGitOpenApi.GET("/auth/github/status").pipe( + Effect.map(({ body }) => body.status), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const loginGithub = (label: string | null) => - dockerGitOpenApi.openApiJsonSchema(GithubStatusResponseSchema, (client) => - client.POST("/auth/github/login", { - body: { label } - })).pipe( - Effect.map((response) => response.status) - ) + dockerGitOpenApi.POST("/auth/github/login", { + body: { label } + }).pipe( + Effect.map(({ body }) => body.status), + Effect.mapError(renderDockerGitOpenApiFailure) + ) export const loginGithubStream = (label: string | null, onChunk: (chunk: string) => void) => requestTextStream({ @@ -251,10 +251,12 @@ export const loginCodexStream = (label: string | null, onChunk: (chunk: string) }) export const logoutCodex = (label: string | null) => - dockerGitOpenApi.openApiJsonSchema(CodexStatusResponseSchema, (client) => - client.POST("/auth/codex/logout", { - body: { label } - })).pipe(Effect.asVoid) + dockerGitOpenApi.POST("/auth/codex/logout", { + body: { label } + }).pipe( + Effect.asVoid, + Effect.mapError(renderDockerGitOpenApiFailure) + ) export const loadProjectEvents = ( projectId: string, @@ -269,42 +271,35 @@ export const loadProjectEvents = ( ) export const loadAuthSnapshot = () => - dockerGitOpenApi.openApiJsonSchema(AuthSnapshotResponseSchema, (client) => client.GET("/auth/menu")).pipe( - Effect.map((response) => response.snapshot) + dockerGitOpenApi.GET("/auth/menu").pipe( + Effect.map(({ body }) => normalizeAuthSnapshot(body.snapshot)), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const runAuthMenuFlow = (request: AuthMenuRequestBody & { readonly flow: AuthMenuFlow }) => - dockerGitOpenApi.openApiJsonSchema( - AuthSnapshotResponseSchema, - (client) => client.POST("/auth/menu", { body: request }) - ).pipe( - Effect.map((response) => response.snapshot) + dockerGitOpenApi.POST("/auth/menu", { body: request }).pipe( + Effect.map(({ body }) => normalizeAuthSnapshot(body.snapshot)), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const loadProjectAuthSnapshot = (projectId: string) => - dockerGitOpenApi.openApiJsonSchema( - ProjectAuthSnapshotResponseSchema, - (client) => - client.GET("/projects/{projectId}/auth/menu", { - params: { path: { projectId } } - }) - ).pipe( - Effect.map((response) => response.snapshot) + dockerGitOpenApi.GET("/projects/{projectId}/auth/menu", { + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => normalizeProjectAuthSnapshot(body.snapshot)), + Effect.mapError(renderDockerGitOpenApiFailure) ) export const runProjectAuthFlow = ( projectId: string, request: ProjectAuthMenuRequestBody & { readonly flow: ProjectAuthFlow } ) => - dockerGitOpenApi.openApiJsonSchema( - ProjectAuthSnapshotResponseSchema, - (client) => - client.POST("/projects/{projectId}/auth/menu", { - body: request, - params: { path: { projectId } } - }) - ).pipe( - Effect.map((response) => response.snapshot) + dockerGitOpenApi.POST("/projects/{projectId}/auth/menu", { + body: request, + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => normalizeProjectAuthSnapshot(body.snapshot)), + Effect.mapError(renderDockerGitOpenApiFailure) ) export { resolveApiBaseUrl } from "./api-http.js" diff --git a/packages/app/tests/docker-git/api-terminal.test.ts b/packages/app/tests/docker-git/api-terminal.test.ts index 54a94c22..47022ff8 100644 --- a/packages/app/tests/docker-git/api-terminal.test.ts +++ b/packages/app/tests/docker-git/api-terminal.test.ts @@ -9,40 +9,35 @@ type CapturedDeleteRequest = { readonly route: string } -type MinimalDeleteClient = { - readonly DELETE: ( +const capturedDeleteRequests = vi.hoisted((): Array => []) +const deleteMock = vi.hoisted(() => + vi.fn(( route: string, options: { readonly params: { readonly path: Readonly> } } - ) => void -} - -const capturedDeleteRequests = vi.hoisted((): Array => []) -const openApiVoidMock = vi.hoisted(() => - vi.fn((request: (client: MinimalDeleteClient) => void) => { - const client: MinimalDeleteClient = { - DELETE: (route, options) => { - capturedDeleteRequests.push({ - params: options.params.path, - route - }) - } - } - request(client) - return Effect.void + ) => { + capturedDeleteRequests.push({ + params: options.params.path, + route + }) + return Effect.succeed({ + body: { ok: true }, + contentType: "application/json", + status: 200 + }) }) ) vi.mock("../../src/web/api-http.js", () => ({ dockerGitOpenApi: { - openApiJsonSchema: vi.fn(), - openApiVoid: openApiVoidMock - } + DELETE: deleteMock + }, + renderDockerGitOpenApiFailure: vi.fn(String) })) describe("api terminal helpers", () => { beforeEach(() => { capturedDeleteRequests.length = 0 - openApiVoidMock.mockClear() + deleteMock.mockClear() }) it.effect("routes auth terminal close paths through the typed OpenAPI endpoint", () => diff --git a/packages/app/tests/docker-git/openapi-effect-client.test.ts b/packages/app/tests/docker-git/openapi-effect-client.test.ts index 0f08a3db..452647ad 100644 --- a/packages/app/tests/docker-git/openapi-effect-client.test.ts +++ b/packages/app/tests/docker-git/openapi-effect-client.test.ts @@ -1,27 +1,35 @@ -import * as ParseResult from "@effect/schema/ParseResult" -import * as Schema from "@effect/schema/Schema" import { describe, expect, it } from "@effect/vitest" import { createClient } from "@prover-coder-ai/docker-git-openapi" -import type { ApiTransportValue } from "@prover-coder-ai/docker-git-openapi" -import { Effect, Either } from "effect" +import type { ApiFailure } from "@prover-coder-ai/docker-git-openapi" +import { Effect } from "effect" import * as fc from "fast-check" +import type { JsonValue } from "../../src/shared/json-schema.js" +import { renderDockerGitOpenApiFailure } from "../../src/web/api-http.js" + type CapturedRequest = { readonly headers: Headers readonly method: string readonly url: string } -const HealthResponseSchema = Schema.Struct({ - cwd: Schema.String, - ok: Schema.Boolean, - projectsRoot: Schema.String, - revision: Schema.NullOr(Schema.String) -}) +type ApiErrorEnvelope = { + readonly error: { + readonly details?: string + readonly message: string + readonly type: string + } +} -const NullableStringTransportValue = fc.option(fc.string(), { nil: null }) +type InternalErrorResponses = { + readonly 500: { + readonly content: { + readonly "application/json": ApiErrorEnvelope + } + } +} -const createJsonResponse = (status: number, value: ApiTransportValue): Response => +const createJsonResponse = (status: number, value: JsonValue): Response => Response.json(value, { headers: { "content-type": "application/json" @@ -29,6 +37,12 @@ const createJsonResponse = (status: number, value: ApiTransportValue): Response status }) +const nullableErrorDetailsArbitrary = fc.option(fc.string(), { nil: null }) + +const errorMessageArbitrary = fc.string({ minLength: 1 }) + +const baseUrlOriginArbitrary = fc.webUrl().map((url) => new URL(url).origin) + const createMockFetch = ( requests: Array, response: Response @@ -82,10 +96,11 @@ const assertOpenApiClientProperty = (property: fc.IAsyncProperty

{ - it.effect("executes typed GET requests through openapi-effect and decodes JSON with Schema", () => + it.effect("executes typed GET requests directly through openapi-effect", () => Effect.gen(function*(_) { const requests: Array = [] const api = createClient({ + baseUrl: "https://docker-git.example.test", fetch: createMockFetch( requests, createJsonResponse(200, { @@ -94,13 +109,13 @@ describe("docker-git OpenAPI Effect client", () => { projectsRoot: "/workspace/projects", revision: null }) - ), - resolveBaseUrl: () => "https://docker-git.example.test" + ) }) - const decoded = yield* _(api.openApiJsonSchema(HealthResponseSchema, (client) => client.GET("/health"))) + const success = yield* _(api.GET("/health")) - expect(decoded).toEqual({ + expect(success.status).toBe(200) + expect(success.body).toEqual({ cwd: "/workspace", ok: true, projectsRoot: "/workspace/projects", @@ -113,25 +128,36 @@ describe("docker-git OpenAPI Effect client", () => { expect(new URL(requests[0]?.url ?? "").searchParams.has("_")).toBe(true) })) - it.effect("property: schema decoding preserves JSON null transport values", () => + it.effect("property: nested API error envelopes preserve their message through UI rendering", () => assertOpenApiClientSyncProperty( - fc.property(NullableStringTransportValue, (value) => - Either.match(ParseResult.decodeUnknownEither(Schema.Null)(value), { - onLeft: () => - value !== null, - onRight: (decoded) => decoded === value - })) + fc.property(nullableErrorDetailsArbitrary, errorMessageArbitrary, (details, message) => { + const body: ApiErrorEnvelope = { + error: { + ...(details !== null && { details }), + message, + type: "Internal" + } + } + const failure: ApiFailure = { + _tag: "HttpError", + body, + contentType: "application/json", + status: 500 + } + return renderDockerGitOpenApiFailure(failure).includes(JSON.stringify(message)) + }) )) it.effect("property: GET requests always include no-cache transport invariants", () => assertOpenApiClientProperty( fc.asyncProperty( - fc.webUrl().map((url) => new URL(url).origin), + baseUrlOriginArbitrary, (baseUrl) => Effect.runPromise( Effect.gen(function*(_) { const requests: Array = [] const api = createClient({ + baseUrl, fetch: createMockFetch( requests, createJsonResponse(200, { @@ -140,28 +166,27 @@ describe("docker-git OpenAPI Effect client", () => { projectsRoot: "/workspace/projects", revision: null }) - ), - resolveBaseUrl: () => baseUrl + ) }) - const result = yield* _( - Effect.either(api.openApiJsonSchema(HealthResponseSchema, (client) => client.GET("/health"))) - ) + const result = yield* _(Effect.either(api.GET("/health"))) expect(result._tag).toBe("Right") expect(requests).toHaveLength(1) expect(requests[0]?.method).toBe("GET") expect(requests[0]?.headers.get("accept")).toBe("application/json") expect(requests[0]?.headers.get("cache-control")).toContain("no-cache") + expect(new URL(requests[0]?.url ?? "").origin).toBe(baseUrl) expect(new URL(requests[0]?.url ?? "").searchParams.has("_")).toBe(true) }) ) ) )) - it.effect("renders nested API error envelopes from openapi-effect failures", () => + it.effect("renders nested API error envelopes from direct openapi-effect failures", () => Effect.gen(function*(_) { const api = createClient({ + baseUrl: "https://docker-git.example.test", fetch: createMockFetch( [], createJsonResponse(500, { @@ -170,13 +195,11 @@ describe("docker-git OpenAPI Effect client", () => { type: "Internal" } }) - ), - resolveBaseUrl: () => "https://docker-git.example.test" + ) }) - const result = yield* _( - Effect.either(api.openApiJsonSchema(HealthResponseSchema, (client) => client.GET("/health"))) - ) + const healthResult = api.GET("/health").pipe(Effect.mapError(renderDockerGitOpenApiFailure)) + const result = yield* _(Effect.either(healthResult)) expect(result._tag).toBe("Left") if (result._tag === "Left") { @@ -184,28 +207,18 @@ describe("docker-git OpenAPI Effect client", () => { } })) - it.effect("preserves JSON null as a valid schema-decoded transport value", () => - Effect.gen(function*(_) { - const api = createClient({ - fetch: createMockFetch([], createJsonResponse(200, null)), - resolveBaseUrl: () => "https://docker-git.example.test" - }) - - const value = yield* _(api.openApiJsonSchema(Schema.Null, (client) => client.GET("/health"))) - - expect(value).toBeNull() - })) - - it.effect("treats 200 ok command responses as successful void effects", () => + it.effect("treats 200 ok command responses as successful direct client effects", () => Effect.gen(function*(_) { const requests: Array = [] const api = createClient({ - fetch: createMockFetch(requests, createJsonResponse(200, { ok: true })), - resolveBaseUrl: () => "https://docker-git.example.test" + baseUrl: "https://docker-git.example.test", + fetch: createMockFetch(requests, createJsonResponse(200, { ok: true })) }) - yield* _(api.openApiVoid((client) => client.POST("/projects/down-all"))) + const success = yield* _(api.POST("/projects/down-all")) + expect(success.status).toBe(200) + expect(success.body).toEqual({ ok: true }) expect(requests).toHaveLength(1) expect(requests[0]?.method).toBe("POST") expect(new URL(requests[0]?.url ?? "").pathname).toBe("/projects/down-all") diff --git a/packages/openapi/package.json b/packages/openapi/package.json index 28eedc94..f1ee6a67 100644 --- a/packages/openapi/package.json +++ b/packages/openapi/package.json @@ -27,9 +27,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@effect/schema": "^0.75.5", - "@prover-coder-ai/openapi-effect": "^1.0.27", - "effect": "^3.21.3" + "@prover-coder-ai/openapi-effect": "^1.0.27" }, "devDependencies": { "openapi-typescript": "^7.13.0", diff --git a/packages/openapi/src/client.ts b/packages/openapi/src/client.ts index 6d333859..b2c221dd 100644 --- a/packages/openapi/src/client.ts +++ b/packages/openapi/src/client.ts @@ -1,122 +1,56 @@ -import * as ParseResult from "@effect/schema/ParseResult" -import type * as Schema from "@effect/schema/Schema" -import * as TreeFormatter from "@effect/schema/TreeFormatter" -import { createClientEffect } from "@prover-coder-ai/openapi-effect" -import type { BoundaryError, ClientOptions, Middleware } from "@prover-coder-ai/openapi-effect" -import { Effect, Either, Match } from "effect" +import { createClientEffect, mergeHeaders } from "@prover-coder-ai/openapi-effect" +import type { + ClientEffect, + ClientOptions, + Middleware +} from "@prover-coder-ai/openapi-effect" import type { paths } from "./openapi-paths.js" -type DockerGitOpenApiTransportClient = ReturnType> +export type { + ApiFailure, + ApiSuccess, + BoundaryError, + DecodeError, + HttpError, + ParseError, + TransportError, + UnexpectedContentType, + UnexpectedStatus +} from "@prover-coder-ai/openapi-effect" -type DockerGitOpenApiMiddleware = Middleware +export type DockerGitOpenApiClient = ClientEffect -export type ApiTransportValue = - | undefined - | null - | boolean - | number - | string - | ReadonlyArray - | { readonly [key: string]: ApiTransportValue } +export type DockerGitOpenApiClientOptions = ClientOptions -type OpenApiSuccess = { - readonly status: number | string - readonly contentType: string - readonly body: unknown -} - -type OpenApiHttpError = OpenApiSuccess & { - readonly _tag: "HttpError" -} - -type OpenApiFailure = OpenApiHttpError | BoundaryError - -type OpenApiRequestResult = Effect.Effect - -type OpenApiRequest = (client: DockerGitOpenApiTransportClient) => OpenApiRequestResult - -export type DockerGitOpenApiClientOptions = { - readonly fetch?: ClientOptions["fetch"] - readonly resolveBaseUrl: () => string -} - -export type DockerGitOpenApiClient = { - readonly openApiJsonSchema: ( - schema: Schema.Schema, - request: OpenApiRequest - ) => Effect.Effect - readonly openApiVoid: (request: OpenApiRequest) => Effect.Effect -} - -type RunOpenApi = (request: OpenApiRequest) => Effect.Effect - -const noCacheHeaders: Readonly> = { +/** + * Default JSON no-cache headers for docker-git OpenAPI requests. + * + * @pure true - immutable header constant. + * @effect none. + * @invariant every configured client request has JSON accept and no-cache directives unless overridden downstream. + * @precondition none. + * @postcondition header keys are safe to pass to Fetch Headers. + * @complexity O(1)/O(1). + * @throws Never. + */ +export const openApiJsonNoCacheHeaders: Readonly> = { accept: "application/json", "cache-control": "no-cache, no-store, max-age=0", pragma: "no-cache" } -const stringifyJson = (value: unknown): Effect.Effect => - Effect.try({ - try: () => JSON.stringify(value, null, 2), - catch: () => null - }) - -const safeJson = (value: unknown): Effect.Effect => - stringifyJson(value).pipe( - Effect.orElseSucceed(() => "unrenderable response payload") - ) - -const renderTransportValue = (value: unknown): Effect.Effect => { - if (typeof value === "string") { - return Effect.succeed(value) - } - if (typeof value === "object" && value !== null && "message" in value) { - const message = value["message"] - if (typeof message === "string") { - return Effect.succeed(message) - } - } - return safeJson(value) -} - -const isApiTransportValue = (value: unknown): value is ApiTransportValue => { - if ( - value === undefined - || value === null - || typeof value === "boolean" - || typeof value === "number" - || typeof value === "string" - ) { - return true - } - if (Array.isArray(value)) { - return value.every(isApiTransportValue) - } - if (typeof value !== "object") { - return false - } - return Object.values(value).every(isApiTransportValue) -} - -const decodeTransportValue = (value: unknown): Effect.Effect => - isApiTransportValue(value) - ? Effect.succeed(value) - : renderTransportValue(value).pipe( - Effect.flatMap((rendered) => Effect.fail(`Invalid JSON response payload: ${rendered}`)) - ) - -const renderOpenApiHttpError = ( - error: OpenApiHttpError -): Effect.Effect => { - if (String(error.status) === "429") { - return Effect.succeed("HTTP 429: tunnel or proxy rate limited the request. Retry or request a fresh tunnel URL.") - } - return error.body === undefined ? Effect.succeed(`HTTP ${error.status}`) : renderTransportValue(error.body) -} - -const noCacheGetMiddleware: DockerGitOpenApiMiddleware = { +// CHANGE: Keep browser GETs cache-busted while returning the raw openapi-effect client. +// WHY: UI polling must not reuse stale JSON, but response typing belongs to openapi-effect. +// QUOTE(ТЗ): "Зачем нам прослойка? Если client сам возвращает нужную схему рабочую" +// REF: user-openapi-effect-direct-client +// SOURCE: n/a +// FORMAT THEOREM: forall GET request r: url(createClient(r)) contains fresh cache key. +// PURITY: SHELL +// EFFECT: none +// INVARIANT: middleware mutates only GET request URLs, never response values or error channels. +// COMPLEXITY: O(1)/O(1) +const noCacheGetMiddleware: Middleware = { onRequest: ({ request }) => { if (request.method !== "GET") { return @@ -127,138 +61,31 @@ const noCacheGetMiddleware: DockerGitOpenApiMiddleware = { } } -/** - * Creates a typed openapi-effect transport client for the docker-git JSON REST API. - * - * @param baseUrl - Absolute API base URL. - * @param fetch - Optional fetch implementation for tests or custom runtimes. - * @returns Typed Effect OpenAPI transport client with no-cache headers and GET cache-busting middleware. - * - * @pure false - constructs a browser/Fetch API HTTP client adapter. - * @effect none - client construction only; returned methods describe network IO as Effect. - * @invariant client paths are constrained by generated DockerGit OpenAPI paths. - * @precondition baseUrl points at a docker-git API server or compatible proxy. - * @postcondition returned client sends no-cache headers on JSON requests. - * @complexity O(1)/O(1) - * @throws Never. - */ -const createTransportClient = ( - baseUrl: string, - fetch?: ClientOptions["fetch"] -): DockerGitOpenApiTransportClient => { - const clientOptions: ClientOptions = fetch === undefined - ? { - baseUrl, - headers: noCacheHeaders - } - : { - baseUrl, - fetch, - headers: noCacheHeaders - } - const client = createClientEffect(clientOptions) - client.use(noCacheGetMiddleware) - return client -} - -const renderOpenApiFailure = (failure: OpenApiFailure): Effect.Effect => - Match.value(failure).pipe( - Match.when({ _tag: "HttpError" }, renderOpenApiHttpError), - Match.when({ _tag: "TransportError" }, (error) => Effect.succeed(error.error.message)), - Match.when({ _tag: "UnexpectedStatus" }, (error) => Effect.succeed(`HTTP ${error.status}: ${error.body}`)), - Match.when({ _tag: "UnexpectedContentType" }, (error) => - Effect.succeed(`HTTP ${error.status}: unexpected content type ${error.actual ?? "none"}: ${error.body}`) - ), - Match.when({ _tag: "ParseError" }, (error) => - Effect.succeed(`HTTP ${error.status}: invalid ${error.contentType} response: ${error.error.message}`) - ), - Match.when({ _tag: "DecodeError" }, (error) => - Effect.succeed(`HTTP ${error.status}: invalid decoded response: ${error.error.message}`) - ), - Match.exhaustive - ) - -const failRenderedOpenApiFailure = (failure: OpenApiFailure): Effect.Effect => - renderOpenApiFailure(failure).pipe( - Effect.flatMap((message) => Effect.fail(message)) - ) - -const openApiJsonWithRunner = ( - runner: RunOpenApi, - request: OpenApiRequest -): Effect.Effect => - runner(request).pipe( - Effect.catchAll(failRenderedOpenApiFailure), - Effect.flatMap((success) => - success.body === undefined - ? Effect.fail(`HTTP ${success.status}: empty response`) - : decodeTransportValue(success.body) - ) - ) - -const decodeSchema = (schema: Schema.Schema, value: ApiTransportValue): Effect.Effect => - Either.match(ParseResult.decodeUnknownEither(schema)(value), { - onLeft: (error) => Effect.fail(TreeFormatter.formatIssueSync(error)), - onRight: (decoded) => Effect.succeed(decoded) - }) - -const openApiJsonSchemaWithRunner = ( - runner: RunOpenApi, - schema: Schema.Schema, - request: OpenApiRequest -): Effect.Effect => - openApiJsonWithRunner(runner, request).pipe( - Effect.flatMap((data) => decodeSchema(schema, data)) - ) - -const openApiVoidWithRunner = ( - runner: RunOpenApi, - request: OpenApiRequest -): Effect.Effect => - runner(request).pipe( - Effect.asVoid, - Effect.catchAll(failRenderedOpenApiFailure) - ) +const withDockerGitDefaults = ( + options: DockerGitOpenApiClientOptions | undefined +): ClientOptions => ({ + ...options, + headers: mergeHeaders(openApiJsonNoCacheHeaders, options?.headers) +}) /** - * Creates a reusable Effect OpenAPI client backed by a base URL resolver. + * Creates the docker-git OpenAPI Effect client. * - * @param options - Client configuration containing a base URL resolver. - * @returns OpenAPI client with a baseUrl-keyed transport cache. + * @param options - openapi-effect client options. + * @returns Typed `ClientEffect` for the generated docker-git OpenAPI contract. * - * @pure false - closes over mutable client cache for client reuse in a shell boundary. - * @effect none during construction; returned helpers perform HTTP IO when their Effects run. - * @invariant cache is keyed only by resolved baseUrl and invalidated on baseUrl change. - * @precondition resolveBaseUrl is deterministic for the duration of a single request Effect. - * @postcondition consumers can share one configured OpenAPI client without importing transport details. - * @complexity O(1)/O(1) for client lookup, excluding request execution. + * @pure false - constructs a Fetch-backed client and installs request middleware. + * @effect none during construction; client methods return Effect values for network IO. + * @invariant returned methods are exactly the openapi-effect methods over generated `paths`. + * @precondition `options.baseUrl` points at a docker-git API server or compatible proxy. + * @postcondition GET requests carry a cache-busting query parameter and JSON no-cache headers. + * @complexity O(1)/O(1), excluding request execution. * @throws Never. */ export const createClient = ( - options: DockerGitOpenApiClientOptions + options?: DockerGitOpenApiClientOptions ): DockerGitOpenApiClient => { - const clientCache: { - baseUrl: string | null - client: DockerGitOpenApiTransportClient | null - } = { - baseUrl: null, - client: null - } - - const getOpenApiClient = (): DockerGitOpenApiTransportClient => { - const baseUrl = options.resolveBaseUrl() - if (clientCache.client === null || clientCache.baseUrl !== baseUrl) { - clientCache.baseUrl = baseUrl - clientCache.client = createTransportClient(baseUrl, options.fetch) - } - return clientCache.client - } - - const runRuntimeOpenApi = (request: OpenApiRequest): Effect.Effect => - request(getOpenApiClient()) - - return { - openApiJsonSchema: (schema, request) => openApiJsonSchemaWithRunner(runRuntimeOpenApi, schema, request), - openApiVoid: (request) => openApiVoidWithRunner(runRuntimeOpenApi, request) - } + const client = createClientEffect(withDockerGitDefaults(options)) + client.use(noCacheGetMiddleware) + return client } From 753f7b43ed47a2ee3fceb4f05bc1e9d8f0f00ee2 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:03:08 +0000 Subject: [PATCH 7/8] test(openapi): type async property errors --- packages/app/tests/docker-git/openapi-effect-client.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/tests/docker-git/openapi-effect-client.test.ts b/packages/app/tests/docker-git/openapi-effect-client.test.ts index 452647ad..1a9e6a82 100644 --- a/packages/app/tests/docker-git/openapi-effect-client.test.ts +++ b/packages/app/tests/docker-git/openapi-effect-client.test.ts @@ -91,7 +91,7 @@ const assertOpenApiClientSyncProperty = (property: fc.IProperty(property: fc.IAsyncProperty) => Effect.tryPromise({ - catch: (cause) => cause, + catch: (error) => (error instanceof Error ? error : new Error(String(error))), try: () => fc.assert(property, { numRuns: 25 }) }) From 66b76115393e24b5dbcb0b3ba05c1eadef075a7d Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:19:26 +0000 Subject: [PATCH 8/8] fix(web): address openapi effect review --- packages/app/src/web/api-create-project.ts | 24 ++ packages/app/src/web/api-database.ts | 299 ++++++++++++++++++++- packages/app/src/web/api-http.ts | 2 +- packages/app/src/web/api-prompts.ts | 8 +- packages/app/src/web/api-skills.ts | 8 +- packages/app/src/web/api-tasks.ts | 10 +- 6 files changed, 329 insertions(+), 22 deletions(-) diff --git a/packages/app/src/web/api-create-project.ts b/packages/app/src/web/api-create-project.ts index 040e5c87..22a2ab3e 100644 --- a/packages/app/src/web/api-create-project.ts +++ b/packages/app/src/web/api-create-project.ts @@ -38,6 +38,30 @@ export const createProjectAcceptedBody = (draft: CreateProjectRequestDraft): Cre ...optionalProjectResourceFields(draft) }) +// CHANGE: Publish the async project creation boundary with an explicit Effect signature. +// WHY: exported web API helpers must expose typed success, error, and requirement channels. +// QUOTE(ТЗ): "исправь" +// REF: PR#431 CodeRabbit review 4535473023 +// SOURCE: n/a +// FORMAT THEOREM: forall draft d: accepted(d) -> Effect. +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: only HTTP 202 is accepted as the asynchronous creation success branch. +// COMPLEXITY: O(1)/O(1), excluding HTTP transport. +/** + * Starts asynchronous project creation through the typed OpenAPI client. + * + * @param draft - Validated project creation draft plus optional resource limits. + * @returns Effect that resolves to the accepted async creation response. + * + * @pure false - performs HTTP IO when the returned Effect is executed. + * @effect Effect + * @invariant HTTP 202 returns the accepted response; HTTP 201 is rejected as a sync branch mismatch. + * @precondition draft fields were validated by the UI create flow. + * @postcondition downstream callers observe only accepted async responses or string-rendered failures. + * @complexity O(1)/O(1), excluding HTTP transport and response body size. + * @throws Never - failures are represented in the Effect error channel. + */ export const startCreateProject = ( draft: CreateProjectRequestDraft ): Effect.Effect => diff --git a/packages/app/src/web/api-database.ts b/packages/app/src/web/api-database.ts index 33c440c8..90db26cb 100644 --- a/packages/app/src/web/api-database.ts +++ b/packages/app/src/web/api-database.ts @@ -1,14 +1,88 @@ import { Effect } from "effect" import { dockerGitOpenApi, renderDockerGitOpenApiFailure } from "./api-http.js" -import type { ProjectDatabaseForward, ProjectDatabaseSession } from "./api-schema.js" +import type { ProjectDatabaseForward, ProjectDatabaseProfile, ProjectDatabaseSession } from "./api-schema.js" +// CHANGE: Document the pure database editor URL projection. +// WHY: exported DB helpers should state their CORE/SHELL boundary and invariant explicitly. +// QUOTE(ТЗ): "исправь" +// REF: PR#431 CodeRabbit review 4535473023 +// SOURCE: n/a +// FORMAT THEOREM: forall session s: projectDatabaseEditorUrl(s) = s.editorPath. +// PURITY: CORE +// EFFECT: none +// INVARIANT: result is a direct projection of the decoded session. +// COMPLEXITY: O(1)/O(1). +/** + * Reads the in-app editor URL from a database session. + * + * @param session - Database editor session returned by the API. + * @returns Editor path for browser navigation. + * + * @pure true - deterministic projection from immutable input. + * @effect none + * @invariant result = session.editorPath. + * @precondition session was decoded from the API schema. + * @postcondition no network or DOM side effects are performed. + * @complexity O(1)/O(1). + * @throws Never. + */ export const projectDatabaseEditorUrl = (session: ProjectDatabaseSession): string => session.editorPath +// CHANGE: Document the pure database external address formatter. +// WHY: exported DB helpers should state their CORE/SHELL boundary and invariant explicitly. +// QUOTE(ТЗ): "исправь" +// REF: PR#431 CodeRabbit review 4535473023 +// SOURCE: n/a +// FORMAT THEOREM: forall forward f: projectDatabaseExternalUrl(f) = f.publicHost + ":" + f.hostPort. +// PURITY: CORE +// EFFECT: none +// INVARIANT: result is derived only from publicHost and hostPort. +// COMPLEXITY: O(1)/O(1). +/** + * Formats the external database forward address. + * + * @param forward - Database forward returned by the API. + * @returns Host and port pair for external clients. + * + * @pure true - deterministic projection from immutable input. + * @effect none + * @invariant result = `${publicHost}:${hostPort}`. + * @precondition forward was decoded from the API schema. + * @postcondition no network or DOM side effects are performed. + * @complexity O(1)/O(1). + * @throws Never. + */ export const projectDatabaseExternalUrl = (forward: ProjectDatabaseForward): string => `${forward.publicHost}:${forward.hostPort}` -export const loadProjectDatabaseProfiles = (projectId: string) => +// CHANGE: Publish database profile loading with an explicit Effect boundary type. +// WHY: exported OpenAPI helpers must expose success, error, and requirement channels without inference drift. +// QUOTE(ТЗ): "исправь" +// REF: PR#431 CodeRabbit review 4535473023 +// SOURCE: n/a +// FORMAT THEOREM: forall projectId p: loadProfiles(p) -> Effect, string, never>. +// PURITY: SHELL +// EFFECT: Effect, string, never> +// INVARIANT: response body snapshot is reduced to its immutable profiles collection. +// COMPLEXITY: O(1)/O(1), excluding HTTP transport. +/** + * Loads database connection profiles for a project. + * + * @param projectId - Project identifier accepted by the API route. + * @returns Effect with the immutable profile collection. + * + * @pure false - performs HTTP IO when the returned Effect is executed. + * @effect Effect, string, never> + * @invariant successful responses expose exactly body.profiles to callers. + * @precondition projectId names an existing project for successful responses. + * @postcondition transport failures are rendered into the string error channel. + * @complexity O(1)/O(1), excluding HTTP transport and response size. + * @throws Never - failures are represented in the Effect error channel. + */ +export const loadProjectDatabaseProfiles = ( + projectId: string +): Effect.Effect, string> => dockerGitOpenApi.GET("/projects/{projectId}/databases/profiles", { params: { path: { projectId } } }).pipe( @@ -16,7 +90,33 @@ export const loadProjectDatabaseProfiles = (projectId: string) => Effect.mapError(renderDockerGitOpenApiFailure) ) -export const loadProjectDatabaseForwards = (projectId: string) => +// CHANGE: Publish database forward loading with an explicit Effect boundary type. +// WHY: callers should depend on the API contract, not inferred generated-client internals. +// QUOTE(ТЗ): "исправь" +// REF: PR#431 CodeRabbit review 4535473023 +// SOURCE: n/a +// FORMAT THEOREM: forall projectId p: loadForwards(p) -> Effect, string, never>. +// PURITY: SHELL +// EFFECT: Effect, string, never> +// INVARIANT: successful responses expose exactly body.forwards. +// COMPLEXITY: O(1)/O(1), excluding HTTP transport. +/** + * Loads active database forwards for a project. + * + * @param projectId - Project identifier accepted by the API route. + * @returns Effect with the immutable forward collection. + * + * @pure false - performs HTTP IO when the returned Effect is executed. + * @effect Effect, string, never> + * @invariant successful responses expose exactly body.forwards to callers. + * @precondition projectId names an existing project for successful responses. + * @postcondition transport failures are rendered into the string error channel. + * @complexity O(1)/O(1), excluding HTTP transport and response size. + * @throws Never - failures are represented in the Effect error channel. + */ +export const loadProjectDatabaseForwards = ( + projectId: string +): Effect.Effect, string> => dockerGitOpenApi.GET("/projects/{projectId}/databases/forwards", { params: { path: { projectId } } }).pipe( @@ -24,11 +124,37 @@ export const loadProjectDatabaseForwards = (projectId: string) => Effect.mapError(renderDockerGitOpenApiFailure) ) +// CHANGE: Publish profile persistence with an explicit Effect boundary type. +// WHY: callers should receive the saved profile DTO and a typed string failure channel. +// QUOTE(ТЗ): "исправь" +// REF: PR#431 CodeRabbit review 4535473023 +// SOURCE: n/a +// FORMAT THEOREM: forall input i: saveProfile(i) -> Effect. +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: successful responses expose exactly body.profile. +// COMPLEXITY: O(1)/O(1), excluding HTTP transport. +/** + * Saves a database connection profile for a project. + * + * @param projectId - Project identifier accepted by the API route. + * @param connectionString - Database connection string to persist. + * @param label - Optional user-facing profile label. + * @returns Effect with the saved database profile. + * + * @pure false - performs HTTP IO when the returned Effect is executed. + * @effect Effect + * @invariant successful responses expose exactly body.profile to callers. + * @precondition connectionString is accepted by the API validator for successful responses. + * @postcondition transport failures are rendered into the string error channel. + * @complexity O(1)/O(1), excluding HTTP transport and response size. + * @throws Never - failures are represented in the Effect error channel. + */ export const saveProjectDatabaseProfile = ( projectId: string, connectionString: string, label: string | null -) => +): Effect.Effect => dockerGitOpenApi.POST("/projects/{projectId}/databases/profiles", { body: { connectionString, label }, params: { path: { projectId } } @@ -37,10 +163,35 @@ export const saveProjectDatabaseProfile = ( Effect.mapError(renderDockerGitOpenApiFailure) ) +// CHANGE: Publish profile deletion as an explicit void Effect. +// WHY: DELETE success body is not consumed by the UI and should not leak generated response details. +// QUOTE(ТЗ): "исправь" +// REF: PR#431 CodeRabbit review 4535473023 +// SOURCE: n/a +// FORMAT THEOREM: forall ids: deleteProfile(ids) -> Effect. +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: successful HTTP deletion maps to void. +// COMPLEXITY: O(1)/O(1), excluding HTTP transport. +/** + * Deletes a database profile from a project. + * + * @param projectId - Project identifier accepted by the API route. + * @param profileId - Database profile identifier accepted by the API route. + * @returns Effect that completes with void on deletion success. + * + * @pure false - performs HTTP IO when the returned Effect is executed. + * @effect Effect + * @invariant successful deletion has no UI-facing payload. + * @precondition projectId and profileId identify an existing profile for successful responses. + * @postcondition transport failures are rendered into the string error channel. + * @complexity O(1)/O(1), excluding HTTP transport and response size. + * @throws Never - failures are represented in the Effect error channel. + */ export const deleteProjectDatabaseProfile = ( projectId: string, profileId: string -) => +): Effect.Effect => dockerGitOpenApi.DELETE("/projects/{projectId}/databases/profiles/{profileId}", { params: { path: { profileId, projectId } } }).pipe( @@ -48,10 +199,35 @@ export const deleteProjectDatabaseProfile = ( Effect.mapError(renderDockerGitOpenApiFailure) ) +// CHANGE: Publish profile exposure with an explicit forward Effect. +// WHY: the shell boundary should expose the normalized domain DTO rather than inferred response wrappers. +// QUOTE(ТЗ): "исправь" +// REF: PR#431 CodeRabbit review 4535473023 +// SOURCE: n/a +// FORMAT THEOREM: forall ids: exposeProfile(ids) -> Effect. +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: successful responses expose exactly body.forward. +// COMPLEXITY: O(1)/O(1), excluding HTTP transport. +/** + * Exposes a database profile through a public forward. + * + * @param projectId - Project identifier accepted by the API route. + * @param profileId - Database profile identifier accepted by the API route. + * @returns Effect with the created or existing forward. + * + * @pure false - performs HTTP IO when the returned Effect is executed. + * @effect Effect + * @invariant successful responses expose exactly body.forward to callers. + * @precondition projectId and profileId identify an exposable database profile. + * @postcondition transport failures are rendered into the string error channel. + * @complexity O(1)/O(1), excluding HTTP transport and response size. + * @throws Never - failures are represented in the Effect error channel. + */ export const exposeProjectDatabaseProfile = ( projectId: string, profileId: string -) => +): Effect.Effect => dockerGitOpenApi.POST("/projects/{projectId}/databases/profiles/{profileId}/expose", { params: { path: { profileId, projectId } } }).pipe( @@ -59,10 +235,35 @@ export const exposeProjectDatabaseProfile = ( Effect.mapError(renderDockerGitOpenApiFailure) ) +// CHANGE: Publish forward deletion as an explicit void Effect. +// WHY: DELETE success is operational, while callers only need completion or a rendered failure. +// QUOTE(ТЗ): "исправь" +// REF: PR#431 CodeRabbit review 4535473023 +// SOURCE: n/a +// FORMAT THEOREM: forall ids: deleteForward(ids) -> Effect. +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: successful HTTP deletion maps to void. +// COMPLEXITY: O(1)/O(1), excluding HTTP transport. +/** + * Deletes the public forward for a database profile. + * + * @param projectId - Project identifier accepted by the API route. + * @param profileId - Database profile identifier accepted by the API route. + * @returns Effect that completes with void on deletion success. + * + * @pure false - performs HTTP IO when the returned Effect is executed. + * @effect Effect + * @invariant successful deletion has no UI-facing payload. + * @precondition projectId and profileId identify an existing forward for successful responses. + * @postcondition transport failures are rendered into the string error channel. + * @complexity O(1)/O(1), excluding HTTP transport and response size. + * @throws Never - failures are represented in the Effect error channel. + */ export const deleteProjectDatabaseForward = ( projectId: string, profileId: string -) => +): Effect.Effect => dockerGitOpenApi.DELETE("/projects/{projectId}/databases/profiles/{profileId}/expose", { params: { path: { profileId, projectId } } }).pipe( @@ -70,7 +271,33 @@ export const deleteProjectDatabaseForward = ( Effect.mapError(renderDockerGitOpenApiFailure) ) -export const loadProjectDatabaseSession = (projectId: string) => +// CHANGE: Publish database session loading with an explicit Effect boundary type. +// WHY: editor consumers should receive the session DTO directly and a typed string error channel. +// QUOTE(ТЗ): "исправь" +// REF: PR#431 CodeRabbit review 4535473023 +// SOURCE: n/a +// FORMAT THEOREM: forall projectId p: loadSession(p) -> Effect. +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: successful responses expose exactly body.session. +// COMPLEXITY: O(1)/O(1), excluding HTTP transport. +/** + * Loads the current database editor session for a project. + * + * @param projectId - Project identifier accepted by the API route. + * @returns Effect with the database editor session. + * + * @pure false - performs HTTP IO when the returned Effect is executed. + * @effect Effect + * @invariant successful responses expose exactly body.session to callers. + * @precondition projectId names an existing project for successful responses. + * @postcondition transport failures are rendered into the string error channel. + * @complexity O(1)/O(1), excluding HTTP transport and response size. + * @throws Never - failures are represented in the Effect error channel. + */ +export const loadProjectDatabaseSession = ( + projectId: string +): Effect.Effect => dockerGitOpenApi.GET("/projects/{projectId}/databases/session", { params: { path: { projectId } } }).pipe( @@ -78,7 +305,33 @@ export const loadProjectDatabaseSession = (projectId: string) => Effect.mapError(renderDockerGitOpenApiFailure) ) -export const openProjectDatabaseEditor = (projectId: string) => +// CHANGE: Publish database editor startup with an explicit Effect boundary type. +// WHY: callers should observe the session DTO, not generated response shape details. +// QUOTE(ТЗ): "исправь" +// REF: PR#431 CodeRabbit review 4535473023 +// SOURCE: n/a +// FORMAT THEOREM: forall projectId p: openEditor(p) -> Effect. +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: successful responses expose exactly body.session. +// COMPLEXITY: O(1)/O(1), excluding HTTP transport. +/** + * Opens the database editor session for a project. + * + * @param projectId - Project identifier accepted by the API route. + * @returns Effect with the opened database editor session. + * + * @pure false - performs HTTP IO when the returned Effect is executed. + * @effect Effect + * @invariant successful responses expose exactly body.session to callers. + * @precondition projectId names an existing project for successful responses. + * @postcondition transport failures are rendered into the string error channel. + * @complexity O(1)/O(1), excluding HTTP transport and response size. + * @throws Never - failures are represented in the Effect error channel. + */ +export const openProjectDatabaseEditor = ( + projectId: string +): Effect.Effect => dockerGitOpenApi.POST("/projects/{projectId}/databases/open", { params: { path: { projectId } } }).pipe( @@ -86,7 +339,33 @@ export const openProjectDatabaseEditor = (projectId: string) => Effect.mapError(renderDockerGitOpenApiFailure) ) -export const restartProjectDatabaseEditor = (projectId: string) => +// CHANGE: Publish database editor restart with an explicit Effect boundary type. +// WHY: restart is a shell operation whose observable result is the refreshed session DTO. +// QUOTE(ТЗ): "исправь" +// REF: PR#431 CodeRabbit review 4535473023 +// SOURCE: n/a +// FORMAT THEOREM: forall projectId p: restartEditor(p) -> Effect. +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: successful responses expose exactly body.session. +// COMPLEXITY: O(1)/O(1), excluding HTTP transport. +/** + * Restarts the database editor session for a project. + * + * @param projectId - Project identifier accepted by the API route. + * @returns Effect with the restarted database editor session. + * + * @pure false - performs HTTP IO when the returned Effect is executed. + * @effect Effect + * @invariant successful responses expose exactly body.session to callers. + * @precondition projectId names an existing project for successful responses. + * @postcondition transport failures are rendered into the string error channel. + * @complexity O(1)/O(1), excluding HTTP transport and response size. + * @throws Never - failures are represented in the Effect error channel. + */ +export const restartProjectDatabaseEditor = ( + projectId: string +): Effect.Effect => dockerGitOpenApi.POST("/projects/{projectId}/databases/restart", { params: { path: { projectId } } }).pipe( diff --git a/packages/app/src/web/api-http.ts b/packages/app/src/web/api-http.ts index 76600bb0..85a9eafb 100644 --- a/packages/app/src/web/api-http.ts +++ b/packages/app/src/web/api-http.ts @@ -138,7 +138,7 @@ export const renderDockerGitOpenApiFailure = (failure: RenderableOpenApiFailure) ? "HTTP 429: tunnel or proxy rate limited the request. Retry or request a fresh tunnel URL." : renderOpenApiBody(error.body)), Match.when({ _tag: "TransportError" }, (error) => error.error.message), - Match.when({ _tag: "UnexpectedStatus" }, (error) => `HTTP ${error.status}: ${error.body}`), + Match.when({ _tag: "UnexpectedStatus" }, (error) => `HTTP ${error.status}: ${renderOpenApiBody(error.body)}`), Match.when({ _tag: "UnexpectedContentType" }, (error) => `HTTP ${error.status}: unexpected content type ${error.actual ?? "none"}: ${error.body}`), Match.when({ _tag: "ParseError" }, (error) => diff --git a/packages/app/src/web/api-prompts.ts b/packages/app/src/web/api-prompts.ts index 77b7ed61..3a9fc557 100644 --- a/packages/app/src/web/api-prompts.ts +++ b/packages/app/src/web/api-prompts.ts @@ -1,9 +1,9 @@ import { Effect } from "effect" import { dockerGitOpenApi, renderDockerGitOpenApiFailure } from "./api-http.js" -import type { ProjectPromptKind } from "./api-schema.js" +import type { ProjectPromptKind, ProjectPromptsSnapshot } from "./api-schema.js" -export const loadProjectPrompts = (projectId: string) => +export const loadProjectPrompts = (projectId: string): Effect.Effect => dockerGitOpenApi.GET("/projects/{projectId}/prompts", { params: { path: { projectId } } }).pipe( @@ -15,7 +15,7 @@ export const writeProjectPrompt = ( projectId: string, kind: ProjectPromptKind, content: string -) => +): Effect.Effect => dockerGitOpenApi.PUT("/projects/{projectId}/prompts/{kind}", { body: { content }, params: { path: { kind, projectId } } @@ -27,7 +27,7 @@ export const writeProjectPrompt = ( export const deleteProjectPrompt = ( projectId: string, kind: ProjectPromptKind -) => +): Effect.Effect => dockerGitOpenApi.DELETE("/projects/{projectId}/prompts/{kind}", { params: { path: { kind, projectId } } }).pipe( diff --git a/packages/app/src/web/api-skills.ts b/packages/app/src/web/api-skills.ts index d7e6c17a..45dbab62 100644 --- a/packages/app/src/web/api-skills.ts +++ b/packages/app/src/web/api-skills.ts @@ -1,7 +1,7 @@ import { Effect } from "effect" import { dockerGitOpenApi, renderDockerGitOpenApiFailure } from "./api-http.js" -import type { ProjectSkillScope } from "./api-schema.js" +import type { ProjectSkillScope, ProjectSkillsSnapshot } from "./api-schema.js" const skillScopeIdByScope: Readonly> = { "skills": "skills", @@ -15,7 +15,7 @@ const skillScopeIdByScope: Readonly> = { export const projectSkillScopeToId = (scope: ProjectSkillScope): string => skillScopeIdByScope[scope] -export const loadProjectSkills = (projectId: string) => +export const loadProjectSkills = (projectId: string): Effect.Effect => dockerGitOpenApi.GET("/projects/{projectId}/skills", { params: { path: { projectId } } }).pipe( @@ -28,7 +28,7 @@ export const writeProjectSkill = ( scope: ProjectSkillScope, name: string, content: string -) => +): Effect.Effect => dockerGitOpenApi.POST("/projects/{projectId}/skills", { body: { content, name, scope }, params: { path: { projectId } } @@ -41,7 +41,7 @@ export const deleteProjectSkill = ( projectId: string, scope: ProjectSkillScope, name: string -) => +): Effect.Effect => dockerGitOpenApi.DELETE("/projects/{projectId}/skills/{scopeId}/{name}", { params: { path: { name, projectId, scopeId: projectSkillScopeToId(scope) } } }).pipe( diff --git a/packages/app/src/web/api-tasks.ts b/packages/app/src/web/api-tasks.ts index e796efec..e1c22fba 100644 --- a/packages/app/src/web/api-tasks.ts +++ b/packages/app/src/web/api-tasks.ts @@ -1,8 +1,12 @@ import { Effect } from "effect" import { dockerGitOpenApi, renderDockerGitOpenApiFailure } from "./api-http.js" +import type { ContainerTaskSnapshot } from "./api-schema.js" -export const loadProjectTasks = (projectId: string, shouldIncludeDefault = false) => +export const loadProjectTasks = ( + projectId: string, + shouldIncludeDefault = false +): Effect.Effect => dockerGitOpenApi.GET("/projects/{projectId}/tasks", { params: { path: { projectId }, @@ -16,7 +20,7 @@ export const loadProjectTasks = (projectId: string, shouldIncludeDefault = false export const stopProjectTask = ( projectId: string, pid: number -) => +): Effect.Effect => dockerGitOpenApi.POST("/projects/{projectId}/tasks/{pid}/stop", { params: { path: { pid: String(pid), projectId } } }).pipe( @@ -28,7 +32,7 @@ export const loadProjectTaskLogs = ( projectId: string, pid: number, lines = 200 -) => +): Effect.Effect => dockerGitOpenApi.GET("/projects/{projectId}/tasks/{pid}/logs", { params: { path: { pid: String(pid), projectId },