Skip to content

Commit af1fcad

Browse files
authored
feat: testing handlers in isolation (#606)
* feat: testing handlers in isolation * docs: fix example
1 parent 26cabb6 commit af1fcad

File tree

9 files changed

+696
-659
lines changed

9 files changed

+696
-659
lines changed

.changeset/witty-roses-begin.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"effect-http-node": patch
3+
---
4+
5+
Add `NodeTesting.handler`. It accepts a `Handler.Handler<A, E, R>` and produces an instance
6+
of `HttpClient.HttpClient.Default`. It start the server with the handler in a background
7+
and the produced client has base URL pointing to the server. It behaves exactly like the
8+
`NodeTesting.makeRaw`, but it works with handlers instead of `HttpApp.HttpApp` instances.
9+
10+
It provides a convenient way to test handlers in isolation.
11+
12+
```ts
13+
import { HttpClientRequest } from "@effect/platform"
14+
import { Schema } from "@effect/schema"
15+
import { expect, it } from "@effect/vitest"
16+
import { Effect } from "effect"
17+
import { Api, Handler } from "effect-http"
18+
import { NodeTesting } from "effect-http-node"
19+
20+
const myEndpoint = Api.get("myEndpoint", "/my-endpoint").pipe(
21+
Api.setResponseBody(Schema.Struct({ hello: Schema.String }))
22+
)
23+
24+
const myHandler = Handler.make(myEndpoint, () => Effect.succeed({ hello: "world" }))
25+
26+
it.scoped("myHandler", () =>
27+
Effect.gen(function*() {
28+
const client = yield* NodeTesting.handler(myHandler)
29+
const response = yield* client(HttpClientRequest.get("/my-endpoint"))
30+
31+
expect(response.status).toEqual(200)
32+
expect(yield* response.json).toEqual({ hello: "world" })
33+
}))
34+
```

docs/_config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@ remote_theme: mikearnaldi/just-the-docs
22
search_enabled: true
33
aux_links:
44
"GitHub":
5-
- "//github.com/effect-ts/effect"
5+
- "//github.com/sukovanej/effect-http"

packages/effect-http-node/docgen.json

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,22 @@
22
"$schema": "node_modules/@effect/docgen/schema.json",
33
"exclude": [
44
"src/internal/**/*.ts"
5-
]
5+
],
6+
"examplesCompilerOptions": {
7+
"noEmit": true,
8+
"strict": true,
9+
"skipLibCheck": true,
10+
"moduleResolution": "Bundler",
11+
"module": "ES2022",
12+
"target": "ES2022",
13+
"lib": ["ES2022", "DOM"],
14+
"paths": {
15+
"effect-http": ["../../../effect-http/src/index.js"],
16+
"effect-http/*": ["../../../effect-http/src/*.js"],
17+
"effect-http-security": ["../../../effect-http-security/src/index.js"],
18+
"effect-http-security/*": ["../../../effect-http-security/src/*.js"],
19+
"effect-http-error": ["../../../effect-http-error/src/index.js"],
20+
"effect-http-error/*": ["../../../effect-http-error/src/*.js"]
21+
}
22+
}
623
}

packages/effect-http-node/src/NodeTesting.ts

Lines changed: 49 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,22 @@
33
*
44
* @since 1.0.0
55
*/
6-
import type * as Effect from "effect/Effect"
7-
import type * as Scope from "effect/Scope"
8-
96
import type * as NodeContext from "@effect/platform-node/NodeContext"
107
import type * as Etag from "@effect/platform/Etag"
118
import type * as HttpApp from "@effect/platform/HttpApp"
129
import type * as HttpClient from "@effect/platform/HttpClient"
13-
import type * as HttpClientError from "@effect/platform/HttpClientError"
14-
import type * as HttpClientResponse from "@effect/platform/HttpClientResponse"
1510
import type * as HttpPlatform from "@effect/platform/HttpPlatform"
1611
import type * as HttpServer from "@effect/platform/HttpServer"
1712
import type * as HttpServerRequest from "@effect/platform/HttpServerRequest"
13+
import type * as Effect from "effect/Effect"
14+
import type * as Scope from "effect/Scope"
15+
1816
import type * as Api from "effect-http/Api"
17+
import type * as ApiEndpoint from "effect-http/ApiEndpoint"
1918
import type * as Client from "effect-http/Client"
19+
import type * as Handler from "effect-http/Handler"
2020
import type * as SwaggerRouter from "effect-http/SwaggerRouter"
21+
2122
import * as internal from "./internal/testing.js"
2223

2324
/**
@@ -56,11 +57,7 @@ export const make: <R, E, A extends Api.Api.Any>(
5657
export const makeRaw: <R, E>(
5758
app: HttpApp.Default<E, R>
5859
) => Effect.Effect<
59-
HttpClient.HttpClient<
60-
HttpClientResponse.HttpClientResponse,
61-
HttpClientError.HttpClientError,
62-
Scope.Scope
63-
>,
60+
HttpClient.HttpClient.Default,
6461
never,
6562
| Scope.Scope
6663
| Exclude<
@@ -74,3 +71,45 @@ export const makeRaw: <R, E>(
7471
NodeContext.NodeContext
7572
>
7673
> = internal.makeRaw
74+
75+
/**
76+
* Testing of `Handler.Handler<A, E, R>`.
77+
*
78+
* @example
79+
* import { HttpClientRequest } from "@effect/platform"
80+
* import { Schema } from "@effect/schema"
81+
* import { Effect } from "effect"
82+
* import { Api, Handler } from "effect-http"
83+
* import { NodeTesting } from "effect-http-node"
84+
*
85+
* const myEndpoint = Api.get("myEndpoint", "/my-endpoint").pipe(
86+
* Api.setResponseBody(Schema.Struct({ hello: Schema.String }))
87+
* )
88+
*
89+
* const myHandler = Handler.make(myEndpoint, () => Effect.succeed({ hello: "world" }))
90+
*
91+
* Effect.gen(function*() {
92+
* const client = yield* NodeTesting.handler(myHandler)
93+
* const response = yield* client(HttpClientRequest.get("/my-endpoint"))
94+
*
95+
* assert.deepStrictEqual(response.status, 200)
96+
* assert.deepStrictEqual(yield* response.json, { hello: "world" })
97+
* }).pipe(Effect.scoped, Effect.runPromise)
98+
*
99+
* @category constructors
100+
* @since 1.0.0
101+
*/
102+
export const handler: <A extends ApiEndpoint.ApiEndpoint.Any, E, R>(
103+
app: Handler.Handler<A, E, R>
104+
) => Effect.Effect<
105+
HttpClient.HttpClient.Default,
106+
never,
107+
| Scope.Scope
108+
| Exclude<
109+
Exclude<
110+
Exclude<R, HttpServerRequest.HttpServerRequest | Scope.Scope>,
111+
HttpServer.HttpServer | HttpPlatform.HttpPlatform | Etag.Generator | NodeContext.NodeContext
112+
>,
113+
NodeContext.NodeContext
114+
>
115+
> = internal.handler

packages/effect-http-node/src/internal/testing.ts

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
1+
import { createServer } from "http"
2+
13
import * as NodeContext from "@effect/platform-node/NodeContext"
24
import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"
35
import type * as HttpApp from "@effect/platform/HttpApp"
46
import * as HttpClient from "@effect/platform/HttpClient"
57
import * as HttpClientRequest from "@effect/platform/HttpClientRequest"
8+
import * as HttpRouter from "@effect/platform/HttpRouter"
69
import * as HttpServer from "@effect/platform/HttpServer"
7-
import type * as Api from "effect-http/Api"
8-
import * as Client from "effect-http/Client"
9-
import type * as SwaggerRouter from "effect-http/SwaggerRouter"
1010
import * as Deferred from "effect/Deferred"
1111
import * as Effect from "effect/Effect"
1212
import * as Layer from "effect/Layer"
13-
import { createServer } from "http"
13+
14+
import type * as Api from "effect-http/Api"
15+
import type * as ApiEndpoint from "effect-http/ApiEndpoint"
16+
import * as Client from "effect-http/Client"
17+
import * as Handler from "effect-http/Handler"
18+
import type * as SwaggerRouter from "effect-http/SwaggerRouter"
19+
1420
import * as NodeSwaggerFiles from "../NodeSwaggerFiles.js"
1521

1622
/** @internal */
@@ -27,8 +33,6 @@ const startTestServer = <R, E>(
2733
Effect.flatMap((url) => Deferred.succeed(allocatedUrl, url)),
2834
Effect.flatMap(() => Layer.launch(HttpServer.serve(app))),
2935
Effect.provide(NodeServerLive),
30-
Effect.provide(NodeSwaggerFiles.SwaggerFilesLive),
31-
Effect.provide(NodeContext.layer),
3236
Effect.forkScoped,
3337
Effect.flatMap(() => Deferred.await(allocatedUrl))
3438
))
@@ -39,28 +43,54 @@ export const make = <R, E, A extends Api.Api.Any>(
3943
api: A,
4044
options?: Partial<Client.Options>
4145
) =>
42-
Effect.map(startTestServer(app), (url) =>
43-
Client.make(api, {
44-
...options,
45-
httpClient: (options?.httpClient ?? HttpClient.fetch).pipe(
46+
startTestServer(app).pipe(
47+
Effect.map((url) =>
48+
Client.make(api, {
49+
...options,
50+
httpClient: (options?.httpClient ?? HttpClient.fetch).pipe(
51+
HttpClient.mapRequest(HttpClientRequest.prependUrl(url)),
52+
HttpClient.transformResponse(
53+
HttpClient.withFetchOptions({ keepalive: false })
54+
)
55+
)
56+
})
57+
),
58+
Effect.provide(NodeSwaggerFiles.SwaggerFilesLive),
59+
Effect.provide(NodeContext.layer)
60+
)
61+
62+
/** @internal */
63+
export const makeRaw = <R, E>(
64+
app: HttpApp.Default<E, R | SwaggerRouter.SwaggerFiles>
65+
) =>
66+
startTestServer(app).pipe(
67+
Effect.map((url) =>
68+
HttpClient.fetch.pipe(
4669
HttpClient.mapRequest(HttpClientRequest.prependUrl(url)),
4770
HttpClient.transformResponse(
4871
HttpClient.withFetchOptions({ keepalive: false })
4972
)
5073
)
51-
}))
74+
),
75+
Effect.provide(NodeSwaggerFiles.SwaggerFilesLive),
76+
Effect.provide(NodeContext.layer)
77+
)
5278

5379
/** @internal */
54-
export const makeRaw = <R, E>(
55-
app: HttpApp.Default<E, R | SwaggerRouter.SwaggerFiles>
80+
export const handler = <A extends ApiEndpoint.ApiEndpoint.Any, E, R>(
81+
handler: Handler.Handler<A, E, R>
5682
) =>
57-
Effect.map(startTestServer(app), (url) =>
58-
HttpClient.fetch.pipe(
59-
HttpClient.mapRequest(HttpClientRequest.prependUrl(url)),
60-
HttpClient.transformResponse(
61-
HttpClient.withFetchOptions({ keepalive: false })
83+
startTestServer(HttpRouter.fromIterable([Handler.getRoute(handler)])).pipe(
84+
Effect.map((url) =>
85+
HttpClient.fetch.pipe(
86+
HttpClient.mapRequest(HttpClientRequest.prependUrl(url)),
87+
HttpClient.transformResponse(
88+
HttpClient.withFetchOptions({ keepalive: false })
89+
)
6290
)
63-
))
91+
),
92+
Effect.provide(NodeContext.layer)
93+
)
6494

6595
/** @internal */
6696
const serverUrl = Effect.map(HttpServer.HttpServer, (server) => {

0 commit comments

Comments
 (0)