Skip to content

Commit e9d454d

Browse files
committed
wip
1 parent 7198a2a commit e9d454d

File tree

2 files changed

+179
-2
lines changed

2 files changed

+179
-2
lines changed

packages/effect/src/unstable/httpapi/HttpApiClientFetch.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const make = <
3030
}
3131

3232
return async function(methodAndUrl: string, opts?: {
33+
readonly signal?: AbortSignal | undefined
3334
readonly path?: Record<string, any> | undefined
3435
readonly urlParams?: Record<string, any> | undefined
3536
readonly headers?: Record<string, string> | undefined
@@ -61,7 +62,8 @@ export const make = <
6162

6263
const fetchOptions: RequestInit = {
6364
method,
64-
headers
65+
headers,
66+
signal: opts?.signal ?? null
6567
}
6668
if (opts?.payload !== undefined && opts?.payloadType !== "urlEncoded") {
6769
headers.set("Content-Type", "application/json")
@@ -81,7 +83,11 @@ export const make = <
8183
fetchOptions.body = urlSearchParams.toString()
8284
}
8385

84-
return await fetchImpl(url.toString(), fetchOptions)
86+
const response = await fetchImpl(url.toString(), fetchOptions)
87+
if (!response.ok) {
88+
throw new Error(`HTTP error status: ${response.status}`)
89+
}
90+
return response
8591
} as any
8692
}
8793

@@ -145,6 +151,7 @@ export type EndpointOptions<Endpoint extends HttpApiEndpoint.Any> = Endpoint ext
145151
infer _M,
146152
infer _MR
147153
> ? Simplify<
154+
& { readonly signal?: AbortSignal | undefined }
148155
& (Endpoint["pathSchema"] extends undefined ? {} :
149156
NoRequiredKeysWith<_PathSchema["Encoded"], {
150157
readonly path?: _PathSchema["Encoded"] | undefined
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { NodeHttpServer } from "@effect/platform-node"
2+
import { assert, describe, it } from "@effect/vitest"
3+
import { Effect, Layer, Schema } from "effect"
4+
import { HttpRouter, HttpServer } from "effect/unstable/http"
5+
import {
6+
HttpApi,
7+
HttpApiBuilder,
8+
HttpApiClientFetch,
9+
HttpApiEndpoint,
10+
HttpApiGroup,
11+
HttpApiSchema
12+
} from "effect/unstable/httpapi"
13+
14+
class MissingUser extends Schema.ErrorClass<MissingUser>("MissingUser")({
15+
_tag: Schema.tag("MissingUser"),
16+
message: Schema.String
17+
}, {
18+
httpApiStatus: 404
19+
}) {}
20+
21+
class GoneUser extends HttpApiSchema.EmptyError<GoneUser>()({
22+
tag: "GoneUser",
23+
status: 410
24+
}) {}
25+
26+
const Api = HttpApi.make("api").add(
27+
HttpApiGroup.make("users").add(
28+
HttpApiEndpoint.get("find", "/users/:id", {
29+
path: {
30+
id: Schema.FiniteFromString
31+
},
32+
urlParams: {
33+
query: Schema.String
34+
},
35+
error: [MissingUser, GoneUser],
36+
success: Schema.Struct({
37+
id: Schema.Number,
38+
query: Schema.String
39+
})
40+
}),
41+
HttpApiEndpoint.post("create", "/users", {
42+
payload: Schema.Struct({
43+
name: Schema.String
44+
}),
45+
success: Schema.Struct({
46+
message: Schema.String
47+
})
48+
}),
49+
HttpApiEndpoint.post("createUrlEncoded", "/users/url-encoded", {
50+
payload: HttpApiSchema.withEncoding(
51+
Schema.Struct({
52+
name: Schema.String,
53+
count: Schema.FiniteFromString
54+
}),
55+
{ kind: "UrlParams" }
56+
),
57+
success: Schema.Struct({
58+
name: Schema.String,
59+
count: Schema.Number
60+
})
61+
})
62+
)
63+
)
64+
65+
const UsersLive = HttpApiBuilder.group(
66+
Api,
67+
"users",
68+
(handlers) =>
69+
handlers
70+
.handle("find", (ctx) =>
71+
ctx.path.id === 0
72+
? Effect.fail(new MissingUser({ message: "User not found" }))
73+
: ctx.path.id === -1
74+
? Effect.fail(new GoneUser())
75+
: Effect.succeed({ id: ctx.path.id, query: ctx.urlParams.query }))
76+
.handle("create", (ctx) => Effect.succeed({ message: `hello ${ctx.payload.name}` }))
77+
.handle("createUrlEncoded", (ctx) => Effect.succeed({ name: ctx.payload.name, count: ctx.payload.count }))
78+
)
79+
80+
const HttpLive = HttpRouter.serve(
81+
HttpApiBuilder.layer(Api).pipe(Layer.provide(UsersLive)),
82+
{ disableListenLog: true, disableLogger: true }
83+
).pipe(
84+
Layer.provideMerge(NodeHttpServer.layerTest)
85+
)
86+
87+
const baseUrlEffect = Effect.gen(function*() {
88+
const server = yield* HttpServer.HttpServer
89+
const address = server.address
90+
if (address._tag !== "TcpAddress") {
91+
return yield* Effect.die(new Error("TcpAddress expected"))
92+
}
93+
const host = address.hostname === "0.0.0.0" ? "127.0.0.1" : address.hostname
94+
return `http://${host}:${address.port}`
95+
})
96+
97+
describe("HttpApiClientFetch", () => {
98+
it.effect("calls HttpApi endpoints with path and query params", () =>
99+
Effect.gen(function*() {
100+
const baseUrl = yield* baseUrlEffect
101+
const client = HttpApiClientFetch.make<typeof Api>({ baseUrl })
102+
const response = yield* Effect.promise(() =>
103+
client("GET /users/:id", {
104+
path: { id: "42" },
105+
urlParams: { query: "search" }
106+
})
107+
)
108+
const body = yield* Effect.promise(() => response.json())
109+
assert.deepStrictEqual(body, { id: 42, query: "search" })
110+
}).pipe(Effect.provide(HttpLive)))
111+
112+
it.effect("sends JSON payloads", () =>
113+
Effect.gen(function*() {
114+
const baseUrl = yield* baseUrlEffect
115+
const client = HttpApiClientFetch.make<typeof Api>({ baseUrl })
116+
const response = yield* Effect.promise(() =>
117+
client("POST /users", {
118+
payload: { name: "Ada" }
119+
})
120+
)
121+
const body = yield* Effect.promise(() => response.json())
122+
assert.deepStrictEqual(body, { message: "hello Ada" })
123+
}).pipe(Effect.provide(HttpLive)))
124+
125+
it.effect("sends url-encoded payloads", () =>
126+
Effect.gen(function*() {
127+
const baseUrl = yield* baseUrlEffect
128+
const client = HttpApiClientFetch.make<typeof Api>({ baseUrl })
129+
const response = yield* Effect.promise(() =>
130+
client("POST /users/url-encoded", {
131+
payload: { name: "Ada", count: "2" },
132+
payloadType: "urlEncoded"
133+
})
134+
)
135+
const body = yield* Effect.promise(() => response.json())
136+
assert.deepStrictEqual(body, { name: "Ada", count: 2 })
137+
}).pipe(Effect.provide(HttpLive)))
138+
139+
it.effect("throws on JSON error responses", () =>
140+
Effect.gen(function*() {
141+
const baseUrl = yield* baseUrlEffect
142+
const client = HttpApiClientFetch.make<typeof Api>({ baseUrl })
143+
const error = yield* Effect.tryPromise({
144+
try: () =>
145+
client("GET /users/:id", {
146+
path: { id: "0" },
147+
urlParams: { query: "search" }
148+
}),
149+
catch: (caught) => caught
150+
}).pipe(Effect.flip)
151+
assert.instanceOf(error, Error)
152+
assert.strictEqual(error.message, "HTTP error status: 404")
153+
}).pipe(Effect.provide(HttpLive)))
154+
155+
it.effect("throws on empty error responses", () =>
156+
Effect.gen(function*() {
157+
const baseUrl = yield* baseUrlEffect
158+
const client = HttpApiClientFetch.make<typeof Api>({ baseUrl })
159+
const error = yield* Effect.tryPromise({
160+
try: () =>
161+
client("GET /users/:id", {
162+
path: { id: "-1" },
163+
urlParams: { query: "search" }
164+
}),
165+
catch: (caught) => caught
166+
}).pipe(Effect.flip)
167+
assert.instanceOf(error, Error)
168+
assert.strictEqual(error.message, "HTTP error status: 410")
169+
}).pipe(Effect.provide(HttpLive)))
170+
})

0 commit comments

Comments
 (0)