diff --git a/apps/cloud/src/api.test.ts b/apps/cloud/src/api.test.ts index 9c1f204fc..db79fd37e 100644 --- a/apps/cloud/src/api.test.ts +++ b/apps/cloud/src/api.test.ts @@ -4,6 +4,8 @@ import { HttpApiClient, HttpApiEndpoint, HttpApiGroup, + HttpApiSwagger, + OpenApi, } from "effect/unstable/httpapi"; import { FetchHttpClient, @@ -47,6 +49,7 @@ const ProtectedGroup = HttpApiGroup.make("protected") }), ); const ProtectedTestApi = HttpApi.make("protectedApi").add(ProtectedGroup); +const DocumentationTestApi = ProtectedTestApi.add(AuthGroup).add(OrgGroup); // --------------------------------------------------------------------------- // Stub handlers @@ -127,9 +130,19 @@ const AutumnTestRoutesLive = HttpRouter.add( Effect.succeed(HttpServerResponse.jsonUnsafe({ source: "autumn" })), ); +const TestDocsLive = Layer.mergeAll( + HttpApiSwagger.layer(DocumentationTestApi, { path: "/docs" }), + HttpRouter.add( + "GET", + "/openapi.json", + Effect.succeed(HttpServerResponse.jsonUnsafe(OpenApi.fromApi(DocumentationTestApi))), + ), +); + const TestApiLive = Layer.mergeAll( OrgTestLive, AuthTestLive, + TestDocsLive, ProtectedTestLive, AutumnTestRoutesLive, ).pipe(Layer.provideMerge(RouterConfig), Layer.provideMerge(HttpServer.layerServices)); @@ -190,6 +203,35 @@ layer(TestClientLayer)("handleApiRequest", (it) => { }), ); + it.effect("serves docs without the protected gate", () => + Effect.gen(function* () { + resetState(); + testState.mode = "none"; + + const response = yield* HttpClient.get(`${TEST_BASE_URL}/docs`); + expect(response.status).toBe(200); + const body = yield* response.text; + expect(body).toContain("swagger-ui"); + expect(body).toContain("/auth/me"); + expect(body).toContain("/org/ping"); + expect(body).toContain("/scope"); + }), + ); + + it.effect("serves raw OpenAPI JSON without the protected gate", () => + Effect.gen(function* () { + resetState(); + testState.mode = "none"; + + const response = yield* HttpClient.get(`${TEST_BASE_URL}/openapi.json`); + expect(response.status).toBe(200); + const body = (yield* response.json) as { readonly paths?: Record }; + expect(body.paths).toHaveProperty("/auth/me"); + expect(body.paths).toHaveProperty("/org/ping"); + expect(body.paths).toHaveProperty("/scope"); + }), + ); + it.effect("routes non-auth paths to protected handler", () => Effect.gen(function* () { resetState(); diff --git a/apps/cloud/src/api/docs.ts b/apps/cloud/src/api/docs.ts new file mode 100644 index 000000000..ba3729c81 --- /dev/null +++ b/apps/cloud/src/api/docs.ts @@ -0,0 +1,23 @@ +import { Effect, Layer } from "effect"; +import { HttpRouter, HttpServerResponse } from "effect/unstable/http"; +import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi"; + +import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api"; +import { OrgApi } from "../org/api"; + +import { ProtectedCloudApi } from "./protected-layers"; + +export const CloudOpenApi = ProtectedCloudApi.add(CloudAuthPublicApi).add(CloudAuthApi).add(OrgApi); + +const spec = OpenApi.fromApi(CloudOpenApi); + +export const CloudOpenApiJsonLive = HttpRouter.add( + "GET", + "/openapi.json", + Effect.succeed(HttpServerResponse.jsonUnsafe(spec)), +); + +export const CloudDocsLive = Layer.mergeAll( + HttpApiSwagger.layer(CloudOpenApi, { path: "/docs" }), + CloudOpenApiJsonLive, +); diff --git a/apps/cloud/src/api/protected.ts b/apps/cloud/src/api/protected.ts index 67bbcac56..07f426513 100644 --- a/apps/cloud/src/api/protected.ts +++ b/apps/cloud/src/api/protected.ts @@ -2,7 +2,6 @@ // because `makeExecutionStack` imports `cloudflare:workers`, which the test // harness can't load in the workerd test runtime. -import { HttpApiSwagger } from "effect/unstable/httpapi"; import { HttpRouter, HttpServerRequest } from "effect/unstable/http"; import { Effect, Layer } from "effect"; @@ -24,7 +23,7 @@ import { DbService } from "../services/db"; import { makeExecutionStack } from "../services/execution-stack"; import { HttpResponseError } from "./error-response"; import { RequestScopedServicesLive } from "./layers"; -import { ProtectedCloudApi, ProtectedCloudApiLive, RouterConfig } from "./protected-layers"; +import { ProtectedCloudApiLive, RouterConfig } from "./protected-layers"; import { requestScopedMiddleware } from "./request-scoped"; // Pre-compute the per-plugin `Effect.provideService(extensionService, @@ -207,7 +206,6 @@ export const makeProtectedApiLive = (rsLive: Layer.Layer