diff --git a/bun.lock b/bun.lock index edacee9f..ac3d6c76 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", }, @@ -236,9 +236,7 @@ "name": "@prover-coder-ai/docker-git-openapi", "version": "0.1.0", "dependencies": { - "@effect/schema": "^0.75.5", - "effect": "^3.21.3", - "openapi-fetch": "^0.17.0", + "@prover-coder-ai/openapi-effect": "^1.0.27", }, "devDependencies": { "openapi-typescript": "^7.13.0", @@ -676,6 +674,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.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=="], "@redocly/config": ["@redocly/config@0.22.0", "", {}, "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ=="], @@ -1556,8 +1556,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=="], 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..aeb9497a 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -317,8 +317,8 @@ 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 -// SOURCE: n/a +// REF: user-message-2026-06-19-openapi-effect +// 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/src/web/api-create-project.ts b/packages/app/src/web/api-create-project.ts index ad9e74cd..22a2ab3e 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 { Effect, Match } from "effect" +import { dockerGitOpenApi, renderDockerGitOpenApiFailure } from "./api-http.js" import { type BaseCreateProjectBody, baseCreateProjectBody, @@ -7,9 +8,7 @@ import { optionalProjectResourceFields, type OptionalProjectResourceFieldsBody } 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 @@ -39,10 +38,42 @@ 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 => - 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 31b19dee..90db26cb 100644 --- a/packages/app/src/web/api-database.ts +++ b/packages/app/src/web/api-database.ts @@ -1,121 +1,374 @@ import { Effect } from "effect" -import { - ProjectDatabaseForwardResponseSchema, - ProjectDatabaseForwardsResponseSchema, - ProjectDatabaseProfileResponseSchema, - ProjectDatabaseProfilesResponseSchema, - ProjectDatabaseSessionResponseSchema -} from "./api-schema.js" -import type { ProjectDatabaseForward, ProjectDatabaseSession } from "./api-schema.js" -import { openApiJsonSchema, openApiVoid } from "./openapi-client.js" +import { dockerGitOpenApi, renderDockerGitOpenApiFailure } from "./api-http.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) => - openApiJsonSchema( - ProjectDatabaseProfilesResponseSchema, - (client) => - client.GET("/projects/{projectId}/databases/profiles", { - params: { path: { projectId } } - }) - ).pipe( - Effect.map((response) => response.profiles) +// 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( + Effect.map(({ body }) => body.profiles), + Effect.mapError(renderDockerGitOpenApiFailure) ) -export const loadProjectDatabaseForwards = (projectId: string) => - openApiJsonSchema( - ProjectDatabaseForwardsResponseSchema, - (client) => - client.GET("/projects/{projectId}/databases/forwards", { - params: { path: { projectId } } - }) - ).pipe( - Effect.map((response) => response.forwards) +// 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( + Effect.map(({ body }) => body.forwards), + 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 -) => - openApiJsonSchema( - ProjectDatabaseProfileResponseSchema, - (client) => - client.POST("/projects/{projectId}/databases/profiles", { - body: { connectionString, label }, - params: { path: { projectId } } - }) - ).pipe( - Effect.map((response) => response.profile) +): Effect.Effect => + dockerGitOpenApi.POST("/projects/{projectId}/databases/profiles", { + body: { connectionString, label }, + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => body.profile), + 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 -) => - openApiVoid((client) => - client.DELETE("/projects/{projectId}/databases/profiles/{profileId}", { - params: { path: { profileId, projectId } } - }) +): Effect.Effect => + dockerGitOpenApi.DELETE("/projects/{projectId}/databases/profiles/{profileId}", { + params: { path: { profileId, projectId } } + }).pipe( + Effect.asVoid, + 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 -) => - openApiJsonSchema( - ProjectDatabaseForwardResponseSchema, - (client) => - client.POST("/projects/{projectId}/databases/profiles/{profileId}/expose", { - params: { path: { profileId, projectId } } - }) - ).pipe( - Effect.map((response) => response.forward) +): Effect.Effect => + dockerGitOpenApi.POST("/projects/{projectId}/databases/profiles/{profileId}/expose", { + params: { path: { profileId, projectId } } + }).pipe( + Effect.map(({ body }) => body.forward), + 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 -) => - openApiVoid((client) => - client.DELETE("/projects/{projectId}/databases/profiles/{profileId}/expose", { - params: { path: { profileId, projectId } } - }) +): Effect.Effect => + dockerGitOpenApi.DELETE("/projects/{projectId}/databases/profiles/{profileId}/expose", { + params: { path: { profileId, projectId } } + }).pipe( + Effect.asVoid, + Effect.mapError(renderDockerGitOpenApiFailure) ) -export const loadProjectDatabaseSession = (projectId: string) => - openApiJsonSchema( - ProjectDatabaseSessionResponseSchema, - (client) => - client.GET("/projects/{projectId}/databases/session", { - params: { path: { projectId } } - }) - ).pipe( - Effect.map((response) => response.session) +// 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( + Effect.map(({ body }) => body.session), + Effect.mapError(renderDockerGitOpenApiFailure) ) -export const openProjectDatabaseEditor = (projectId: string) => - openApiJsonSchema( - ProjectDatabaseSessionResponseSchema, - (client) => - client.POST("/projects/{projectId}/databases/open", { - params: { path: { projectId } } - }) - ).pipe( - Effect.map((response) => response.session) +// 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( + Effect.map(({ body }) => body.session), + Effect.mapError(renderDockerGitOpenApiFailure) ) -export const restartProjectDatabaseEditor = (projectId: string) => - openApiJsonSchema( - ProjectDatabaseSessionResponseSchema, - (client) => - client.POST("/projects/{projectId}/databases/restart", { - params: { path: { projectId } } - }) - ).pipe( - Effect.map((response) => response.session) +// 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( + 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 2d844d97..85a9eafb 100644 --- a/packages/app/src/web/api-http.ts +++ b/packages/app/src/web/api-http.ts @@ -2,7 +2,9 @@ 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 { Effect, Either } from "effect" +import { createClient } from "@prover-coder-ai/docker-git-openapi" +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" @@ -18,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" @@ -84,6 +97,72 @@ 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}: ${renderOpenApiBody(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. + * + * @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({ + baseUrl: resolveApiBaseUrl() +}) + export const requestText = ( method: ApiHttpMethod, path: string, 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 565b6143..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, 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" -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,70 +25,76 @@ const createProjectBody = (draft: CreateProjectRequestDraft) => ({ }) export const loadProjectDetails = (projectId: string) => - 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) => - 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) => - 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 ) => - 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) => - 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) => - 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) => - 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) => - 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 d040462f..3a9fc557 100644 --- a/packages/app/src/web/api-prompts.ts +++ b/packages/app/src/web/api-prompts.ts @@ -1,37 +1,36 @@ import { Effect } from "effect" -import { ProjectPromptsResponseSchema, ProjectPromptUpdateResponseSchema } from "./api-schema.js" -import type { ProjectPromptKind } from "./api-schema.js" -import { openApiJsonSchema } from "./openapi-client.js" +import { dockerGitOpenApi, renderDockerGitOpenApiFailure } from "./api-http.js" +import type { ProjectPromptKind, ProjectPromptsSnapshot } from "./api-schema.js" -export const loadProjectPrompts = (projectId: string) => - openApiJsonSchema(ProjectPromptsResponseSchema, (client) => - client.GET("/projects/{projectId}/prompts", { - params: { path: { projectId } } - })).pipe( - Effect.map((response) => response.snapshot) - ) +export const loadProjectPrompts = (projectId: string): Effect.Effect => + dockerGitOpenApi.GET("/projects/{projectId}/prompts", { + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => body.snapshot), + Effect.mapError(renderDockerGitOpenApiFailure) + ) 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) - ) +): Effect.Effect => + 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 -) => - openApiJsonSchema(ProjectPromptsResponseSchema, (client) => - client.DELETE("/projects/{projectId}/prompts/{kind}", { - params: { path: { kind, projectId } } - })).pipe( - Effect.map((response) => response.snapshot) - ) +): Effect.Effect => + 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 fc1e4149..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 { PanelCloudflareTunnelResponseSchema } from "./api-schema.js" -import { openApiJsonSchema } from "./openapi-client.js" +import { dockerGitOpenApi, renderDockerGitOpenApiFailure } from "./api-http.js" +import { normalizeNullablePanelCloudflareTunnelSession } from "./api-normalize.js" /** * Reads the controller-owned panel Cloudflare tunnel session. @@ -25,8 +25,9 @@ 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( - Effect.map((response) => response.tunnel) + dockerGitOpenApi.GET("/cloudflare-tunnels/panel").pipe( + Effect.map(({ body }) => normalizeNullablePanelCloudflareTunnelSession(body.tunnel)), + Effect.mapError(renderDockerGitOpenApiFailure) ) /** @@ -51,12 +52,12 @@ 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.POST("/cloudflare-tunnels/panel", { + body: { panelUrl } + }).pipe( + Effect.map(({ body }) => normalizeNullablePanelCloudflareTunnelSession(body.tunnel)), + Effect.mapError(renderDockerGitOpenApiFailure) + ) /** * Stops the controller-owned panel Cloudflare tunnel. @@ -80,6 +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 = () => - 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 b0ffb147..45dbab62 100644 --- a/packages/app/src/web/api-skills.ts +++ b/packages/app/src/web/api-skills.ts @@ -1,8 +1,7 @@ import { Effect } from "effect" -import { ProjectSkillsResponseSchema, ProjectSkillUpdateResponseSchema } from "./api-schema.js" -import type { ProjectSkillScope } from "./api-schema.js" -import { openApiJsonSchema } from "./openapi-client.js" +import { dockerGitOpenApi, renderDockerGitOpenApiFailure } from "./api-http.js" +import type { ProjectSkillScope, ProjectSkillsSnapshot } from "./api-schema.js" const skillScopeIdByScope: Readonly> = { "skills": "skills", @@ -16,39 +15,36 @@ 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) - ) +export const loadProjectSkills = (projectId: string): Effect.Effect => + dockerGitOpenApi.GET("/projects/{projectId}/skills", { + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => body.snapshot), + Effect.mapError(renderDockerGitOpenApiFailure) + ) export const writeProjectSkill = ( projectId: string, scope: ProjectSkillScope, 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) - ) +): Effect.Effect => + dockerGitOpenApi.POST("/projects/{projectId}/skills", { + body: { content, name, scope }, + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => body.snapshot), + Effect.mapError(renderDockerGitOpenApiFailure) + ) export const deleteProjectSkill = ( projectId: string, scope: ProjectSkillScope, name: string -) => - openApiJsonSchema( - ProjectSkillsResponseSchema, - (client) => - client.DELETE("/projects/{projectId}/skills/{scopeId}/{name}", { - params: { path: { name, projectId, scopeId: projectSkillScopeToId(scope) } } - }) - ).pipe( - Effect.map((response) => response.snapshot) +): Effect.Effect => + 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 e4938207..e1c22fba 100644 --- a/packages/app/src/web/api-tasks.ts +++ b/packages/app/src/web/api-tasks.ts @@ -1,40 +1,44 @@ import { Effect } from "effect" -import { ContainerTaskSnapshotResponseSchema, OutputResponseSchema } from "./api-schema.js" -import { openApiJsonSchema, openApiVoid } from "./openapi-client.js" +import { dockerGitOpenApi, renderDockerGitOpenApiFailure } from "./api-http.js" +import type { ContainerTaskSnapshot } from "./api-schema.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) - ) +export const loadProjectTasks = ( + projectId: string, + shouldIncludeDefault = false +): Effect.Effect => + 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 -) => - openApiVoid((client) => - client.POST("/projects/{projectId}/tasks/{pid}/stop", { - params: { path: { pid: String(pid), projectId } } - }) +): Effect.Effect => + dockerGitOpenApi.POST("/projects/{projectId}/tasks/{pid}/stop", { + params: { path: { pid: String(pid), projectId } } + }).pipe( + Effect.asVoid, + Effect.mapError(renderDockerGitOpenApiFailure) ) export const loadProjectTaskLogs = ( projectId: string, 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) - ) +): Effect.Effect => + 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 47ca25e1..08e3ea86 100644 --- a/packages/app/src/web/api-terminal.ts +++ b/packages/app/src/web/api-terminal.ts @@ -1,128 +1,118 @@ import { Effect } from "effect" -import { - AuthTerminalSessionResponseSchema, - ProjectTerminalSessionResponseSchema, - ProjectTerminalSessionsResponseSchema, - StartProjectTerminalSessionAcceptedResponseSchema, - TerminalSessionLookupResponseSchema, - TerminalSessionResponseSchema -} from "./api-schema.js" -import { openApiJsonSchema, openApiVoid } from "./openapi-client.js" +import { dockerGitOpenApi, renderDockerGitOpenApiFailure } from "./api-http.js" +import { normalizeProjectDetails, normalizeTerminalSession } from "./api-normalize.js" export const createProjectTerminalSession = (projectKey: string) => - 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 ) => - 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 ) => - 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 ) => - 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) => - 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) => - 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) => - 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 ) => - 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 ) => - 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) => - 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 3b2a71ef..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 { 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, @@ -25,7 +20,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,13 +108,19 @@ 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.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))) })) ) @@ -143,85 +143,96 @@ 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.GET("/projects/{projectId}/ports", { + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => body.forwards), + Effect.mapError(renderDockerGitOpenApiFailure) + ) export const loadProjectBrowser = (projectId: string) => - openApiJsonSchema(ProjectBrowserResponseSchema, (client) => - client.GET("/projects/{projectId}/browser", { - params: { path: { projectId } } - })) - .pipe(Effect.map((response) => response.browser)) + dockerGitOpenApi.GET("/projects/{projectId}/browser", { + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => body.browser), + Effect.mapError(renderDockerGitOpenApiFailure) + ) export const startProjectBrowser = (projectId: string) => - openApiJsonSchema(ProjectBrowserResponseSchema, (client) => - client.POST("/projects/{projectId}/browser/start", { - params: { path: { projectId } } - })) - .pipe(Effect.map((response) => response.browser)) + dockerGitOpenApi.POST("/projects/{projectId}/browser/start", { + params: { path: { projectId } } + }).pipe( + Effect.map(({ body }) => body.browser), + Effect.mapError(renderDockerGitOpenApiFailure) + ) export const createProjectPortForward = ( projectId: string, 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.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 ) => - 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) => - 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) => - 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 = () => 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) => - 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 = () => - 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) => - 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({ @@ -240,10 +251,12 @@ export const loginCodexStream = (label: string | null, onChunk: (chunk: string) }) export const logoutCodex = (label: string | null) => - 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, @@ -258,34 +271,36 @@ export const loadProjectEvents = ( ) export const loadAuthSnapshot = () => - 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 }) => - 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) => - 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 } ) => - 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/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..47022ff8 100644 --- a/packages/app/tests/docker-git/api-terminal.test.ts +++ b/packages/app/tests/docker-git/api-terminal.test.ts @@ -9,38 +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/openapi-client.js", () => ({ - openApiJsonSchema: vi.fn(), - openApiVoid: openApiVoidMock +vi.mock("../../src/web/api-http.js", () => ({ + dockerGitOpenApi: { + 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 new file mode 100644 index 00000000..1a9e6a82 --- /dev/null +++ b/packages/app/tests/docker-git/openapi-effect-client.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from "@effect/vitest" +import { createClient } from "@prover-coder-ai/docker-git-openapi" +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 +} + +type ApiErrorEnvelope = { + readonly error: { + readonly details?: string + readonly message: string + readonly type: string + } +} + +type InternalErrorResponses = { + readonly 500: { + readonly content: { + readonly "application/json": ApiErrorEnvelope + } + } +} + +const createJsonResponse = (status: number, value: JsonValue): Response => + Response.json(value, { + headers: { + "content-type": "application/json" + }, + 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 +): (request: Request) => ReturnType => +(request) => { + requests.push({ + headers: request.headers, + method: request.method, + url: request.url + }) + 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. + * + * @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: (error) => (error instanceof Error ? error : new Error(String(error))), + try: () => fc.assert(property, { numRuns: 25 }) + }) + +describe("docker-git OpenAPI Effect client", () => { + 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, { + cwd: "/workspace", + ok: true, + projectsRoot: "/workspace/projects", + revision: null + }) + ) + }) + + const success = yield* _(api.GET("/health")) + + expect(success.status).toBe(200) + expect(success.body).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("property: nested API error envelopes preserve their message through UI rendering", () => + assertOpenApiClientSyncProperty( + 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( + baseUrlOriginArbitrary, + (baseUrl) => + Effect.runPromise( + Effect.gen(function*(_) { + const requests: Array = [] + const api = createClient({ + baseUrl, + fetch: createMockFetch( + requests, + createJsonResponse(200, { + cwd: "/workspace", + ok: true, + projectsRoot: "/workspace/projects", + revision: null + }) + ) + }) + + 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 direct openapi-effect failures", () => + Effect.gen(function*(_) { + const api = createClient({ + baseUrl: "https://docker-git.example.test", + fetch: createMockFetch( + [], + createJsonResponse(500, { + error: { + message: "container snapshot failed", + type: "Internal" + } + }) + ) + }) + + const healthResult = api.GET("/health").pipe(Effect.mapError(renderDockerGitOpenApiFailure)) + const result = yield* _(Effect.either(healthResult)) + + expect(result._tag).toBe("Left") + if (result._tag === "Left") { + expect(result.left).toContain("container snapshot failed") + } + })) + + it.effect("treats 200 ok command responses as successful direct client effects", () => + Effect.gen(function*(_) { + const requests: Array = [] + const api = createClient({ + baseUrl: "https://docker-git.example.test", + fetch: createMockFetch(requests, createJsonResponse(200, { ok: true })) + }) + + 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 0e748a23..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", - "effect": "^3.21.3", - "openapi-fetch": "^0.17.0" + "@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 208b9418..b2c221dd 100644 --- a/packages/openapi/src/client.ts +++ b/packages/openapi/src/client.ts @@ -1,89 +1,55 @@ -import * as ParseResult from "@effect/schema/ParseResult" -import type * as Schema from "@effect/schema/Schema" -import * as TreeFormatter from "@effect/schema/TreeFormatter" -import { Effect, Either, Option } from "effect" -import createClient, { type Client, type Middleware } from "openapi-fetch" +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" -export type DockerGitOpenApiClient = Client +export type { + ApiFailure, + ApiSuccess, + BoundaryError, + DecodeError, + HttpError, + ParseError, + TransportError, + UnexpectedContentType, + UnexpectedStatus +} from "@prover-coder-ai/openapi-effect" -export type ApiTransportValue = - | undefined - | null - | boolean - | number - | string - | ReadonlyArray - | { readonly [key: string]: ApiTransportValue } +export type DockerGitOpenApiClient = ClientEffect -export type ApiTransportError = ApiTransportValue | object +export type DockerGitOpenApiClientOptions = ClientOptions -export type OpenApiResponse = { - readonly data?: A - readonly error?: ApiTransportError - readonly response: Response -} - -export type OpenApiRequestResult = PromiseLike> - -export type OpenApiRequest = (client: DockerGitOpenApiClient) => OpenApiRequestResult - -export type DockerGitOpenApiRuntimeOptions = { - readonly resolveBaseUrl: () => string -} - -export type DockerGitOpenApiRuntime = { - readonly openApiJson: (request: OpenApiRequest) => Effect.Effect - readonly openApiJsonSchema: ( - schema: Schema.Schema, - request: OpenApiRequest - ) => Effect.Effect - readonly openApiVoid: (request: OpenApiRequest) => Effect.Effect -} - -type RunOpenApi = (request: OpenApiRequest) => Effect.Effect, string> - -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: ApiTransportError): Effect.Effect => - Effect.try({ - try: () => JSON.stringify(value, null, 2), - catch: () => null - }) - -const safeJson = (value: ApiTransportError): Effect.Effect => - stringifyJson(value).pipe( - Effect.orElseSucceed(() => "unrenderable response payload") - ) - -const renderTransportValue = (value: ApiTransportError): 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 renderOpenApiError = ( - response: Response, - error: ApiTransportError | undefined -): 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.") - } - return error === undefined ? Effect.succeed(`HTTP ${response.status}`) : renderTransportValue(error) -} - +// 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") { @@ -95,218 +61,31 @@ const noCacheGetMiddleware: Middleware = { } } +const withDockerGitDefaults = ( + options: DockerGitOpenApiClientOptions | undefined +): ClientOptions => ({ + ...options, + headers: mergeHeaders(openApiJsonNoCacheHeaders, options?.headers) +}) + /** - * Creates a typed openapi-fetch client for the docker-git JSON REST API. + * Creates the docker-git OpenAPI Effect client. * - * @param baseUrl - Absolute API base URL. - * @returns Typed OpenAPI client with no-cache headers and GET cache-busting middleware. + * @param options - openapi-effect client options. + * @returns Typed `ClientEffect` for the generated docker-git OpenAPI contract. * - * @pure false - constructs a browser/Fetch API HTTP client adapter. - * @effect none - client construction only; network IO happens when request methods are executed. - * @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) + * @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 createDockerGitOpenApiClient = (baseUrl: string): DockerGitOpenApiClient => { - const client = createClient({ - baseUrl, - headers: noCacheHeaders - }) +export const createClient = ( + options?: DockerGitOpenApiClientOptions +): DockerGitOpenApiClient => { + const client = createClientEffect(withDockerGitDefaults(options)) client.use(noCacheGetMiddleware) return client } - -/** - * Runs a typed OpenAPI request with a provided client through Effect. - * - * @param client - Typed docker-git OpenAPI client. - * @param request - Deferred openapi-fetch 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. - * @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, - request: OpenApiRequest -): Effect.Effect, string> => - Effect.tryPromise({ - try: () => request(client), - catch: String - }) - -const failRenderedOpenApiError = ( - response: Response, - error: ApiTransportError | undefined -): Effect.Effect => - renderOpenApiError(response, error).pipe( - Effect.flatMap((message) => Effect.fail(message)) - ) - -const openApiJsonWithRunner = ( - runner: RunOpenApi, - request: OpenApiRequest -): Effect.Effect => - runner(request).pipe( - Effect.flatMap(({ data, error, response }) => - 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) - }) - : failRenderedOpenApiError(response, error), - onSome: (apiError) => failRenderedOpenApiError(response, apiError) - }) - ) - ) - -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.flatMap(({ error, response }) => - response.ok - ? Option.match(Option.fromNullable(error), { - onNone: () => Effect.void, - onSome: (apiError) => failRenderedOpenApiError(response, apiError) - }) - : failRenderedOpenApiError(response, error) - ) - ) - -/** - * Executes a typed OpenAPI JSON request through a provided client. - * - * @param client - Typed docker-git OpenAPI client. - * @param request - Deferred typed openapi-fetch 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. - * @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, - 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 client. - * @param schema - Boundary decoder preserving the consumer DTO type. - * @param request - Deferred typed openapi-fetch 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. - * @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: DockerGitOpenApiClient, - 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 client. - * @param request - Deferred typed openapi-fetch 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. - * @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: DockerGitOpenApiClient, - request: OpenApiRequest -): Effect.Effect => - openApiVoidWithRunner((nextRequest) => runOpenApi(client, nextRequest), request) - -/** - * Creates reusable Effect helpers 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. - * - * @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. - * @complexity O(1)/O(1) for client lookup, excluding request execution. - * @throws Never. - */ -export const makeDockerGitOpenApiRuntime = ( - options: DockerGitOpenApiRuntimeOptions -): DockerGitOpenApiRuntime => { - const clientCache: { - baseUrl: string | null - client: DockerGitOpenApiClient | null - } = { - baseUrl: null, - client: null - } - - const getOpenApiClient = (): DockerGitOpenApiClient => { - const baseUrl = options.resolveBaseUrl() - if (clientCache.client === null || clientCache.baseUrl !== baseUrl) { - clientCache.baseUrl = baseUrl - clientCache.client = createDockerGitOpenApiClient(baseUrl) - } - return clientCache.client - } - - const runRuntimeOpenApi = (request: OpenApiRequest): Effect.Effect, string> => - Effect.tryPromise({ - try: () => request(getOpenApiClient()), - catch: String - }) - - return { - openApiJson: (request) => openApiJsonWithRunner(runRuntimeOpenApi, request), - openApiJsonSchema: (schema, request) => openApiJsonSchemaWithRunner(runRuntimeOpenApi, schema, request), - openApiVoid: (request) => openApiVoidWithRunner(runRuntimeOpenApi, request) - } -}