@effect/platform-cloudflare 0.0.0-stage → 4.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +434 -0
- package/CLAUDE.md +434 -0
- package/LICENSE +21 -0
- package/README.md +505 -2
- package/ai-docs/README.md +44 -0
- package/ai-docs/package.json +36 -0
- package/ai-docs/src/01_effect/01_basics/01_effect-gen.ts +30 -0
- package/ai-docs/src/01_effect/01_basics/02_effect-fn.ts +52 -0
- package/ai-docs/src/01_effect/01_basics/10_creating-effects.ts +74 -0
- package/ai-docs/src/01_effect/01_basics/index.md +7 -0
- package/ai-docs/src/01_effect/02_schema/10_schema-basics.ts +43 -0
- package/ai-docs/src/01_effect/02_schema/index.md +7 -0
- package/ai-docs/src/01_effect/03_services/01_service.ts +45 -0
- package/ai-docs/src/01_effect/03_services/10_reference.ts +10 -0
- package/ai-docs/src/01_effect/03_services/20_layer-composition.ts +70 -0
- package/ai-docs/src/01_effect/03_services/20_layer-unwrap.ts +66 -0
- package/ai-docs/src/01_effect/03_services/index.md +5 -0
- package/ai-docs/src/01_effect/04_errors/01_error-handling.ts +30 -0
- package/ai-docs/src/01_effect/04_errors/10_catch-tags.ts +24 -0
- package/ai-docs/src/01_effect/04_errors/20_reason-errors.ts +64 -0
- package/ai-docs/src/01_effect/04_errors/index.md +1 -0
- package/ai-docs/src/01_effect/05_resources/10_acquire-release.ts +105 -0
- package/ai-docs/src/01_effect/05_resources/20_layer-side-effects.ts +31 -0
- package/ai-docs/src/01_effect/05_resources/30_layer-map.ts +86 -0
- package/ai-docs/src/01_effect/05_resources/index.md +3 -0
- package/ai-docs/src/01_effect/06_running/10_run-main.ts +30 -0
- package/ai-docs/src/01_effect/06_running/20_layer-launch.ts +27 -0
- package/ai-docs/src/01_effect/06_running/index.md +1 -0
- package/ai-docs/src/01_effect/07_pubsub/10_pubsub.ts +56 -0
- package/ai-docs/src/01_effect/07_pubsub/index.md +3 -0
- package/ai-docs/src/03_stream/10_creating-streams.ts +103 -0
- package/ai-docs/src/03_stream/20_consuming-streams.ts +137 -0
- package/ai-docs/src/03_stream/30_encoding.ts +163 -0
- package/ai-docs/src/03_stream/index.md +4 -0
- package/ai-docs/src/04_integration/10_managed-runtime.ts +129 -0
- package/ai-docs/src/04_integration/index.md +5 -0
- package/ai-docs/src/05_batching/10_request-resolver.ts +89 -0
- package/ai-docs/src/05_batching/index.md +3 -0
- package/ai-docs/src/06_schedule/10_schedules.ts +111 -0
- package/ai-docs/src/06_schedule/index.md +3 -0
- package/ai-docs/src/07_datetime/10_creating-and-formatting.ts +30 -0
- package/ai-docs/src/07_datetime/20_time-zones.ts +44 -0
- package/ai-docs/src/07_datetime/index.md +5 -0
- package/ai-docs/src/08_observability/10_logging.ts +66 -0
- package/ai-docs/src/08_observability/20_otlp-tracing.ts +95 -0
- package/ai-docs/src/08_observability/index.md +7 -0
- package/ai-docs/src/09_testing/10_effect-tests.ts +55 -0
- package/ai-docs/src/09_testing/20_layer-tests.ts +138 -0
- package/ai-docs/src/09_testing/index.md +1 -0
- package/ai-docs/src/10_predicate/01_basics.ts +14 -0
- package/ai-docs/src/10_predicate/index.md +9 -0
- package/ai-docs/src/40_sql/10_basics.ts +175 -0
- package/ai-docs/src/40_sql/index.md +6 -0
- package/ai-docs/src/50_http-client/10_basics.ts +102 -0
- package/ai-docs/src/50_http-client/index.md +3 -0
- package/ai-docs/src/51_http-server/10_basics.ts +118 -0
- package/ai-docs/src/51_http-server/20_testing.ts +100 -0
- package/ai-docs/src/51_http-server/fixtures/api/Api.ts +14 -0
- package/ai-docs/src/51_http-server/fixtures/api/Authorization.ts +36 -0
- package/ai-docs/src/51_http-server/fixtures/api/System.ts +10 -0
- package/ai-docs/src/51_http-server/fixtures/api/Users.ts +107 -0
- package/ai-docs/src/51_http-server/fixtures/domain/User.ts +21 -0
- package/ai-docs/src/51_http-server/fixtures/domain/UserErrors.ts +22 -0
- package/ai-docs/src/51_http-server/fixtures/server/Authorization.ts +37 -0
- package/ai-docs/src/51_http-server/fixtures/server/Users/http.ts +80 -0
- package/ai-docs/src/51_http-server/fixtures/server/Users.ts +183 -0
- package/ai-docs/src/51_http-server/index.md +3 -0
- package/ai-docs/src/52_graphql/10_basics.ts +117 -0
- package/ai-docs/src/52_graphql/20_testing.ts +45 -0
- package/ai-docs/src/52_graphql/30_batching.ts +118 -0
- package/ai-docs/src/52_graphql/40_aliased-batching.ts +124 -0
- package/ai-docs/src/52_graphql/50_errors-and-retries.ts +74 -0
- package/ai-docs/src/52_graphql/index.md +6 -0
- package/ai-docs/src/60_child-process/10_working-with-child-processes.ts +117 -0
- package/ai-docs/src/60_child-process/index.md +3 -0
- package/ai-docs/src/70_cli/10_basics.ts +162 -0
- package/ai-docs/src/70_cli/index.md +5 -0
- package/ai-docs/src/71_ai/10_language-model.ts +156 -0
- package/ai-docs/src/71_ai/20_tools.ts +226 -0
- package/ai-docs/src/71_ai/30_chat.ts +158 -0
- package/ai-docs/src/71_ai/fixtures/domain/LaunchPlan.ts +9 -0
- package/ai-docs/src/71_ai/index.md +5 -0
- package/ai-docs/src/80_cluster/10_entities.ts +97 -0
- package/ai-docs/src/80_cluster/index.md +4 -0
- package/ai-docs/src/index.md +10 -0
- package/ai-docs/tsconfig.json +28 -0
- package/dist/AlchemyCloudflareCluster.d.ts +134 -0
- package/dist/AlchemyCloudflareCluster.d.ts.map +1 -0
- package/dist/AlchemyCloudflareCluster.js +111 -0
- package/dist/AlchemyCloudflareCluster.js.map +1 -0
- package/dist/CloudflareCluster.d.ts +196 -0
- package/dist/CloudflareCluster.d.ts.map +1 -0
- package/dist/CloudflareCluster.js +533 -0
- package/dist/CloudflareCluster.js.map +1 -0
- package/dist/CloudflareDurableObjectPrograms.d.ts +279 -0
- package/dist/CloudflareDurableObjectPrograms.d.ts.map +1 -0
- package/dist/CloudflareDurableObjectPrograms.js +295 -0
- package/dist/CloudflareDurableObjectPrograms.js.map +1 -0
- package/dist/CloudflareDurableObjects.d.ts +149 -0
- package/dist/CloudflareDurableObjects.d.ts.map +1 -0
- package/dist/CloudflareDurableObjects.js +337 -0
- package/dist/CloudflareDurableObjects.js.map +1 -0
- package/dist/CloudflarePersistedQueue.d.ts +36 -0
- package/dist/CloudflarePersistedQueue.d.ts.map +1 -0
- package/dist/CloudflarePersistedQueue.js +122 -0
- package/dist/CloudflarePersistedQueue.js.map +1 -0
- package/dist/CloudflareWorkflowEngine.d.ts +72 -0
- package/dist/CloudflareWorkflowEngine.d.ts.map +1 -0
- package/dist/CloudflareWorkflowEngine.js +187 -0
- package/dist/CloudflareWorkflowEngine.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/alchemyCluster.d.ts +2 -0
- package/dist/internal/alchemyCluster.d.ts.map +1 -0
- package/dist/internal/alchemyCluster.js +75 -0
- package/dist/internal/alchemyCluster.js.map +1 -0
- package/dist/internal/boundedMap.d.ts +3 -0
- package/dist/internal/boundedMap.d.ts.map +1 -0
- package/dist/internal/boundedMap.js +15 -0
- package/dist/internal/boundedMap.js.map +1 -0
- package/dist/internal/cluster.d.ts +57 -0
- package/dist/internal/cluster.d.ts.map +1 -0
- package/dist/internal/cluster.js +2 -0
- package/dist/internal/cluster.js.map +1 -0
- package/dist/internal/clusterName.d.ts +2 -0
- package/dist/internal/clusterName.d.ts.map +1 -0
- package/dist/internal/clusterName.js +16 -0
- package/dist/internal/clusterName.js.map +1 -0
- package/dist/internal/entityKeepAlive.d.ts +2 -0
- package/dist/internal/entityKeepAlive.d.ts.map +1 -0
- package/dist/internal/entityKeepAlive.js +73 -0
- package/dist/internal/entityKeepAlive.js.map +1 -0
- package/dist/internal/entityMailbox.d.ts +2 -0
- package/dist/internal/entityMailbox.d.ts.map +1 -0
- package/dist/internal/entityMailbox.js +160 -0
- package/dist/internal/entityMailbox.js.map +1 -0
- package/dist/internal/entityRegistry.d.ts +16 -0
- package/dist/internal/entityRegistry.d.ts.map +1 -0
- package/dist/internal/entityRegistry.js +22 -0
- package/dist/internal/entityRegistry.js.map +1 -0
- package/dist/internal/entityReply.d.ts +2 -0
- package/dist/internal/entityReply.d.ts.map +1 -0
- package/dist/internal/entityReply.js +35 -0
- package/dist/internal/entityReply.js.map +1 -0
- package/dist/internal/entityRuntime.d.ts +2 -0
- package/dist/internal/entityRuntime.d.ts.map +1 -0
- package/dist/internal/entityRuntime.js +823 -0
- package/dist/internal/entityRuntime.js.map +1 -0
- package/dist/internal/entitySql.d.ts +2 -0
- package/dist/internal/entitySql.d.ts.map +1 -0
- package/dist/internal/entitySql.js +29 -0
- package/dist/internal/entitySql.js.map +1 -0
- package/dist/internal/entityStorage.d.ts +2 -0
- package/dist/internal/entityStorage.d.ts.map +1 -0
- package/dist/internal/entityStorage.js +98 -0
- package/dist/internal/entityStorage.js.map +1 -0
- package/dist/internal/entityWire.d.ts +2 -0
- package/dist/internal/entityWire.d.ts.map +1 -0
- package/dist/internal/entityWire.js +146 -0
- package/dist/internal/entityWire.js.map +1 -0
- package/dist/internal/queueRuntime.d.ts +2 -0
- package/dist/internal/queueRuntime.d.ts.map +1 -0
- package/dist/internal/queueRuntime.js +88 -0
- package/dist/internal/queueRuntime.js.map +1 -0
- package/dist/internal/queueStorage.d.ts +2 -0
- package/dist/internal/queueStorage.d.ts.map +1 -0
- package/dist/internal/queueStorage.js +129 -0
- package/dist/internal/queueStorage.js.map +1 -0
- package/dist/internal/registry.d.ts +10 -0
- package/dist/internal/registry.d.ts.map +1 -0
- package/dist/internal/registry.js +24 -0
- package/dist/internal/registry.js.map +1 -0
- package/dist/internal/singletonRegistry.d.ts +2 -0
- package/dist/internal/singletonRegistry.d.ts.map +1 -0
- package/dist/internal/singletonRegistry.js +9 -0
- package/dist/internal/singletonRegistry.js.map +1 -0
- package/dist/internal/singletonRuntime.d.ts +2 -0
- package/dist/internal/singletonRuntime.d.ts.map +1 -0
- package/dist/internal/singletonRuntime.js +42 -0
- package/dist/internal/singletonRuntime.js.map +1 -0
- package/dist/internal/singletonStorage.d.ts +2 -0
- package/dist/internal/singletonStorage.d.ts.map +1 -0
- package/dist/internal/singletonStorage.js +34 -0
- package/dist/internal/singletonStorage.js.map +1 -0
- package/dist/internal/transport.d.ts +2 -0
- package/dist/internal/transport.d.ts.map +1 -0
- package/dist/internal/transport.js +49 -0
- package/dist/internal/transport.js.map +1 -0
- package/dist/internal/workflowRegistry.d.ts +2 -0
- package/dist/internal/workflowRegistry.d.ts.map +1 -0
- package/dist/internal/workflowRegistry.js +29 -0
- package/dist/internal/workflowRegistry.js.map +1 -0
- package/dist/internal/workflowRuntime.d.ts +2 -0
- package/dist/internal/workflowRuntime.d.ts.map +1 -0
- package/dist/internal/workflowRuntime.js +322 -0
- package/dist/internal/workflowRuntime.js.map +1 -0
- package/dist/internal/workflowStorage.d.ts +2 -0
- package/dist/internal/workflowStorage.d.ts.map +1 -0
- package/dist/internal/workflowStorage.js +113 -0
- package/dist/internal/workflowStorage.js.map +1 -0
- package/dist/internal/workflowWire.d.ts +2 -0
- package/dist/internal/workflowWire.d.ts.map +1 -0
- package/dist/internal/workflowWire.js +52 -0
- package/dist/internal/workflowWire.js.map +1 -0
- package/package.json +74 -3
- package/src/AlchemyCloudflareCluster.ts +200 -0
- package/src/CloudflareCluster.ts +704 -0
- package/src/CloudflareDurableObjectPrograms.ts +543 -0
- package/src/CloudflareDurableObjects.ts +430 -0
- package/src/CloudflarePersistedQueue.ts +189 -0
- package/src/CloudflareWorkflowEngine.ts +220 -0
- package/src/index.ts +38 -0
- package/src/internal/alchemyCluster.ts +120 -0
- package/src/internal/boundedMap.ts +15 -0
- package/src/internal/cluster.ts +57 -0
- package/src/internal/clusterName.ts +19 -0
- package/src/internal/entityKeepAlive.ts +121 -0
- package/src/internal/entityMailbox.ts +324 -0
- package/src/internal/entityRegistry.ts +46 -0
- package/src/internal/entityReply.ts +54 -0
- package/src/internal/entityRuntime.ts +1318 -0
- package/src/internal/entitySql.ts +60 -0
- package/src/internal/entityStorage.ts +168 -0
- package/src/internal/entityWire.ts +204 -0
- package/src/internal/queueRuntime.ts +174 -0
- package/src/internal/queueStorage.ts +255 -0
- package/src/internal/registry.ts +31 -0
- package/src/internal/singletonRegistry.ts +21 -0
- package/src/internal/singletonRuntime.ts +88 -0
- package/src/internal/singletonStorage.ts +64 -0
- package/src/internal/transport.ts +67 -0
- package/src/internal/workflowRegistry.ts +78 -0
- package/src/internal/workflowRuntime.ts +443 -0
- package/src/internal/workflowStorage.ts +234 -0
- package/src/internal/workflowWire.ts +123 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @title Testing services with shared layers
|
|
3
|
+
*
|
|
4
|
+
* How to test Effect services that depend on other services.
|
|
5
|
+
*/
|
|
6
|
+
import { assert, describe, it, layer } from "@effect/vitest"
|
|
7
|
+
import { Array, Context, Effect, Layer, Ref } from "effect"
|
|
8
|
+
|
|
9
|
+
export interface Todo {
|
|
10
|
+
readonly id: number
|
|
11
|
+
readonly title: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Create a test ref service that can be used to store and manipulate test data
|
|
15
|
+
// in layers.
|
|
16
|
+
export class TodoRepoTestRef extends Context.Service<TodoRepoTestRef, Ref.Ref<Array<Todo>>>()("app/TodoRepoTestRef") {
|
|
17
|
+
static readonly layer = Layer.effect(TodoRepoTestRef, Ref.make(Array.empty()))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class TodoRepo extends Context.Service<TodoRepo, {
|
|
21
|
+
create(title: string): Effect.Effect<Todo>
|
|
22
|
+
readonly list: Effect.Effect<ReadonlyArray<Todo>>
|
|
23
|
+
}>()("app/TodoRepo") {
|
|
24
|
+
static readonly layerTest = Layer.effect(
|
|
25
|
+
TodoRepo,
|
|
26
|
+
Effect.gen(function*() {
|
|
27
|
+
const store = yield* TodoRepoTestRef
|
|
28
|
+
|
|
29
|
+
const create = Effect.fn("TodoRepo.create")(function*(title: string) {
|
|
30
|
+
const todos = yield* Ref.get(store)
|
|
31
|
+
const todo = { id: todos.length + 1, title }
|
|
32
|
+
yield* Ref.set(store, [...todos, todo])
|
|
33
|
+
return todo
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const list = Ref.get(store)
|
|
37
|
+
|
|
38
|
+
return TodoRepo.of({
|
|
39
|
+
create,
|
|
40
|
+
list
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
).pipe(
|
|
44
|
+
// Provide the test ref layer as a dependency for the test repo layer.
|
|
45
|
+
// Use Layer.provideMerge so the tests can also access the test ref directly
|
|
46
|
+
// if needed.
|
|
47
|
+
Layer.provideMerge(TodoRepoTestRef.layer)
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
class TodoService extends Context.Service<TodoService, {
|
|
52
|
+
addAndCount(title: string): Effect.Effect<number>
|
|
53
|
+
readonly titles: Effect.Effect<ReadonlyArray<string>>
|
|
54
|
+
}>()("app/TodoService") {
|
|
55
|
+
static readonly layerNoDeps = Layer.effect(
|
|
56
|
+
TodoService,
|
|
57
|
+
Effect.gen(function*() {
|
|
58
|
+
const repo = yield* TodoRepo
|
|
59
|
+
|
|
60
|
+
const addAndCount = Effect.fn("TodoService.addAndCount")(function*(title: string) {
|
|
61
|
+
yield* repo.create(title)
|
|
62
|
+
const todos = yield* repo.list
|
|
63
|
+
return todos.length
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const titles = repo.list.pipe(
|
|
67
|
+
Effect.map((todos) => todos.map((todo) => todo.title))
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return TodoService.of({
|
|
71
|
+
addAndCount,
|
|
72
|
+
titles
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
// You would also add a live layer here that provides real dependencies for
|
|
78
|
+
// production code.
|
|
79
|
+
//
|
|
80
|
+
// static readonly layer = Layer.effect(TodoService, ...).pipe(
|
|
81
|
+
// Layer.provide(TodoRepo.layer)
|
|
82
|
+
// )
|
|
83
|
+
|
|
84
|
+
static readonly layerTest = this.layerNoDeps.pipe(
|
|
85
|
+
// Provide the test repo layer as a dependency for the test service layer.
|
|
86
|
+
// Use `Layer.provideMerge` so the tests can also access the test repo
|
|
87
|
+
// directly if needed, as well as the test ref through the repo layer.
|
|
88
|
+
Layer.provideMerge(TodoRepo.layerTest)
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// `layer(...)` creates one shared layer for the block and tears it down in
|
|
93
|
+
// `afterAll`, so all tests inside can access the same service context.
|
|
94
|
+
layer(TodoRepo.layerTest)("TodoRepo", (it) => {
|
|
95
|
+
it.effect("tests repository behavior", () =>
|
|
96
|
+
Effect.gen(function*() {
|
|
97
|
+
const repo = yield* TodoRepo
|
|
98
|
+
const before = (yield* repo.list).length
|
|
99
|
+
assert.strictEqual(before, 0)
|
|
100
|
+
|
|
101
|
+
yield* repo.create("Write docs")
|
|
102
|
+
|
|
103
|
+
const after = (yield* repo.list).length
|
|
104
|
+
assert.strictEqual(after, 1)
|
|
105
|
+
}))
|
|
106
|
+
|
|
107
|
+
it.effect("layer is shared", () =>
|
|
108
|
+
Effect.gen(function*() {
|
|
109
|
+
const repo = yield* TodoRepo
|
|
110
|
+
const before = (yield* repo.list).length
|
|
111
|
+
assert.strictEqual(before, 1)
|
|
112
|
+
|
|
113
|
+
yield* repo.create("Write docs again")
|
|
114
|
+
|
|
115
|
+
// because the layer is shared between tests, the todo created in the
|
|
116
|
+
// previous test is still present, so the count should be 2, not 1
|
|
117
|
+
const after = (yield* repo.list).length
|
|
118
|
+
assert.strictEqual(after, 2)
|
|
119
|
+
}))
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe("TodoService", () => {
|
|
123
|
+
it.effect("tests higher-level service logic", () =>
|
|
124
|
+
Effect.gen(function*() {
|
|
125
|
+
const ref = yield* TodoRepoTestRef
|
|
126
|
+
const service = yield* TodoService
|
|
127
|
+
const count = yield* service.addAndCount("Review docs")
|
|
128
|
+
const titles = yield* service.titles
|
|
129
|
+
|
|
130
|
+
assert.isTrue(count >= 1)
|
|
131
|
+
assert.isTrue(titles.some((title) => title.includes("Review docs")))
|
|
132
|
+
|
|
133
|
+
// You can also access the test ref directly to make assertions about the
|
|
134
|
+
// underlying data.
|
|
135
|
+
const todos = yield* Ref.get(ref)
|
|
136
|
+
assert.isTrue(todos.length >= 1)
|
|
137
|
+
}).pipe(Effect.provide(TodoService.layerTest)))
|
|
138
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
## Testing Effect programs
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
## Runtime type guards
|
|
2
|
+
|
|
3
|
+
The `Predicate` module contains small, reusable runtime checks.
|
|
4
|
+
|
|
5
|
+
**NEVER** write your own helper functions like `isRecord` or `isString`, instead
|
|
6
|
+
use the helpers from the `Predicate` module.
|
|
7
|
+
|
|
8
|
+
Predicates can be composed with apis such as `Predicate.and`,
|
|
9
|
+
`Predicate.or`, `Predicate.not`, and `Predicate.compose`.
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @title Getting started with SQL
|
|
3
|
+
*
|
|
4
|
+
* Define a schema-backed domain model, run migrations against a SQLite
|
|
5
|
+
* database, and expose a derived repository through a service.
|
|
6
|
+
*/
|
|
7
|
+
import { NodeRuntime } from "@effect/platform-node"
|
|
8
|
+
import { SqliteClient, SqliteMigrator } from "@effect/sql-sqlite-node"
|
|
9
|
+
import { Context, Effect, Layer, Schema } from "effect"
|
|
10
|
+
import { Model } from "effect/schema"
|
|
11
|
+
import { SqlClient, SqlModel, SqlSchema } from "effect/sql"
|
|
12
|
+
|
|
13
|
+
// Use a branded string for the group id, so it cannot be mixed up with other
|
|
14
|
+
// string ids in the application.
|
|
15
|
+
export const GroupId = Schema.String.pipe(Schema.brand("GroupId"))
|
|
16
|
+
export type GroupId = typeof GroupId.Type
|
|
17
|
+
|
|
18
|
+
// `Model.Class` defines a domain model with variants for the database and JSON
|
|
19
|
+
// boundaries. The single field declaration is the source of truth, and each
|
|
20
|
+
// variant only contains the fields that make sense for that operation:
|
|
21
|
+
//
|
|
22
|
+
// - `Group` / `Group.insert` / `Group.update` for the database
|
|
23
|
+
// - `Group.json` / `Group.jsonCreate` / `Group.jsonUpdate` for JSON APIs
|
|
24
|
+
export class Group extends Model.Class<Group>("Group")({
|
|
25
|
+
// A UUID v4 primary key that is generated by the application on insert
|
|
26
|
+
id: Model.UuidV4Insert(GroupId),
|
|
27
|
+
name: Schema.NonEmptyString,
|
|
28
|
+
// `Model.FieldExcept` removes a field from the given variants. The slug is
|
|
29
|
+
// chosen when the group is created and is immutable afterwards, so it is
|
|
30
|
+
// removed from the update variants.
|
|
31
|
+
slug: Schema.NonEmptyString.pipe(Model.FieldExcept(["update", "jsonUpdate"])),
|
|
32
|
+
// `Model.FieldOnly` keeps a field in only the given variants. Internal notes
|
|
33
|
+
// live in the database and are never exposed through the JSON variants.
|
|
34
|
+
notes: Schema.NullOr(Schema.String).pipe(Model.FieldOnly(["select", "insert"])),
|
|
35
|
+
// `Model.Field` gives full control over the individual variants. The member
|
|
36
|
+
// count is maintained by the database, so the application can read it but
|
|
37
|
+
// never writes it.
|
|
38
|
+
memberCount: Model.Field({
|
|
39
|
+
select: Schema.Int,
|
|
40
|
+
json: Schema.Int
|
|
41
|
+
}),
|
|
42
|
+
// `createdAt` is set to the current time on insert, and `updatedAt` is
|
|
43
|
+
// refreshed on every update. Both are stored as strings, which suits SQLite.
|
|
44
|
+
createdAt: Model.DateTimeInsert,
|
|
45
|
+
updatedAt: Model.DateTimeUpdate
|
|
46
|
+
}) {}
|
|
47
|
+
|
|
48
|
+
export class GroupNotFound extends Schema.TaggedError<GroupNotFound>()("GroupNotFound", {
|
|
49
|
+
id: GroupId
|
|
50
|
+
}) {}
|
|
51
|
+
|
|
52
|
+
// The SqlClient layer determines which database you are talking to. Swap this
|
|
53
|
+
// layer for `@effect/sql-pg`, `@effect/sql-mysql2` etc. to target another
|
|
54
|
+
// database without changing the rest of the code.
|
|
55
|
+
const SqlLayer = SqliteClient.layer({ filename: ":memory:" })
|
|
56
|
+
|
|
57
|
+
// Migrations are effects keyed by `<id>_<name>` that run once, in id order. A
|
|
58
|
+
// real application would keep each migration in its own file and load them
|
|
59
|
+
// with `SqliteMigrator.fromFileSystem` instead of an inline record.
|
|
60
|
+
const MigratorLayer = SqliteMigrator.layer({
|
|
61
|
+
loader: SqliteMigrator.fromRecord({
|
|
62
|
+
"0001_create_groups": Effect.gen(function*() {
|
|
63
|
+
const sql = yield* SqlClient.SqlClient
|
|
64
|
+
yield* sql`
|
|
65
|
+
CREATE TABLE groups (
|
|
66
|
+
id TEXT PRIMARY KEY,
|
|
67
|
+
name TEXT NOT NULL,
|
|
68
|
+
slug TEXT NOT NULL,
|
|
69
|
+
notes TEXT,
|
|
70
|
+
memberCount INTEGER NOT NULL DEFAULT 0,
|
|
71
|
+
createdAt TEXT NOT NULL,
|
|
72
|
+
updatedAt TEXT NOT NULL
|
|
73
|
+
)
|
|
74
|
+
`
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// Combine the database client with the migrations, so anything built on top of
|
|
80
|
+
// `SqlLive` sees a fully migrated database.
|
|
81
|
+
const SqlLive = MigratorLayer.pipe(Layer.provideMerge(SqlLayer))
|
|
82
|
+
|
|
83
|
+
// Wrap data access in a service, so the rest of the application depends on
|
|
84
|
+
// `Groups` instead of the database directly.
|
|
85
|
+
export class Groups extends Context.Service<Groups, {
|
|
86
|
+
create(name: string, slug: string): Effect.Effect<Group>
|
|
87
|
+
rename(id: GroupId, name: string): Effect.Effect<Group, GroupNotFound>
|
|
88
|
+
findById(id: GroupId): Effect.Effect<Group, GroupNotFound>
|
|
89
|
+
readonly list: Effect.Effect<Array<Group>>
|
|
90
|
+
}>()("app/Groups") {
|
|
91
|
+
static readonly layer = Layer.effect(
|
|
92
|
+
Groups,
|
|
93
|
+
Effect.gen(function*() {
|
|
94
|
+
const sql = yield* SqlClient.SqlClient
|
|
95
|
+
|
|
96
|
+
// `SqlModel.makeRepository` derives insert / update / findById / delete
|
|
97
|
+
// operations from the model, using the matching variant schema for each
|
|
98
|
+
// operation.
|
|
99
|
+
const repo = yield* SqlModel.makeRepository(Group, {
|
|
100
|
+
tableName: "groups",
|
|
101
|
+
spanPrefix: "Groups",
|
|
102
|
+
idColumn: "id"
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// For queries the repository does not cover, combine the `sql` tag with
|
|
106
|
+
// `SqlSchema` to decode the rows using the model schema.
|
|
107
|
+
const listAll = SqlSchema.findAll({
|
|
108
|
+
Request: Schema.Void,
|
|
109
|
+
Result: Group,
|
|
110
|
+
execute: () => sql`SELECT * FROM groups ORDER BY createdAt`
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
// Use `Effect.fn` to give each method a named span for observability.
|
|
114
|
+
const create = Effect.fn("Groups.create")((name: string, slug: string) =>
|
|
115
|
+
// `Group.insert.makeEffect` fills in the generated id and timestamps
|
|
116
|
+
// using the Effect clock, so tests can control them with `TestClock`.
|
|
117
|
+
Group.insert.makeEffect({ name, slug, notes: null }).pipe(
|
|
118
|
+
Effect.flatMap(repo.insert),
|
|
119
|
+
// Database and encoding failures are unexpected here, so treat
|
|
120
|
+
// them as defects to keep the service interface focused on domain
|
|
121
|
+
// errors.
|
|
122
|
+
Effect.orDie
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
const rename = Effect.fn("Groups.rename")((id: GroupId, name: string) =>
|
|
127
|
+
Group.update.makeEffect({ id, name }).pipe(
|
|
128
|
+
Effect.flatMap(repo.update),
|
|
129
|
+
Effect.orDie
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
const findById = Effect.fn("Groups.findById")((id: GroupId) =>
|
|
134
|
+
repo.findById(id).pipe(
|
|
135
|
+
Effect.catchTags({
|
|
136
|
+
NoSuchElementError: () => new GroupNotFound({ id }),
|
|
137
|
+
SchemaError: Effect.die,
|
|
138
|
+
SqlError: Effect.die
|
|
139
|
+
})
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
const list = listAll().pipe(
|
|
144
|
+
Effect.orDie,
|
|
145
|
+
Effect.withSpan("Groups.list")
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return Groups.of({ create, rename, findById, list })
|
|
149
|
+
})
|
|
150
|
+
).pipe(
|
|
151
|
+
// Provide the layers locally, so lots of messy wiring doesn't need to
|
|
152
|
+
// happen in the "main" entrypoint of the application.
|
|
153
|
+
Layer.provide(SqlLive)
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const program = Effect.gen(function*() {
|
|
158
|
+
const groups = yield* Groups
|
|
159
|
+
|
|
160
|
+
const engineering = yield* groups.create("Engineering", "engineering")
|
|
161
|
+
const design = yield* groups.create("Design", "design")
|
|
162
|
+
|
|
163
|
+
yield* groups.rename(design.id, "Product Design")
|
|
164
|
+
|
|
165
|
+
const found = yield* groups.findById(engineering.id)
|
|
166
|
+
yield* Effect.log("found group", found)
|
|
167
|
+
|
|
168
|
+
const all = yield* groups.list
|
|
169
|
+
yield* Effect.log(`total groups: ${all.length}`)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
program.pipe(
|
|
173
|
+
Effect.provide(Groups.layer),
|
|
174
|
+
NodeRuntime.runMain
|
|
175
|
+
)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
## Working with SQL databases
|
|
2
|
+
|
|
3
|
+
Use the `effect/sql` modules together with a driver package such as
|
|
4
|
+
`@effect/sql-sqlite-node` to access SQL databases. Define domain models with
|
|
5
|
+
`Model.Class` to derive schemas for the database and JSON boundaries, run
|
|
6
|
+
migrations, and write type-safe queries.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @title Getting started with HttpClient
|
|
3
|
+
*
|
|
4
|
+
* Define a service that uses the HttpClient module to fetch data from an external API
|
|
5
|
+
*/
|
|
6
|
+
import { Context, Effect, flow, Layer, Schedule, Schema } from "effect"
|
|
7
|
+
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"
|
|
8
|
+
|
|
9
|
+
class Todo extends Schema.Class<Todo>("Todo")({
|
|
10
|
+
userId: Schema.Int,
|
|
11
|
+
id: Schema.Int,
|
|
12
|
+
title: Schema.String,
|
|
13
|
+
completed: Schema.Boolean
|
|
14
|
+
}) {}
|
|
15
|
+
|
|
16
|
+
export class JsonPlaceholder extends Context.Service<JsonPlaceholder, {
|
|
17
|
+
readonly allTodos: Effect.Effect<ReadonlyArray<Todo>, JsonPlaceholderError>
|
|
18
|
+
getTodo(id: number): Effect.Effect<Todo, JsonPlaceholderError>
|
|
19
|
+
createTodo(todo: Omit<Todo, "id">): Effect.Effect<Todo, JsonPlaceholderError>
|
|
20
|
+
}>()("app/JsonPlaceholder") {
|
|
21
|
+
static readonly layer = Layer.effect(
|
|
22
|
+
JsonPlaceholder,
|
|
23
|
+
Effect.gen(function*() {
|
|
24
|
+
// Access the HttpClient service, and apply some common middleware to all
|
|
25
|
+
// requests:
|
|
26
|
+
const client = (yield* HttpClient.HttpClient).pipe(
|
|
27
|
+
// Add a base URL to all requests made with this client, and set the
|
|
28
|
+
// Accept header to expect JSON responses
|
|
29
|
+
HttpClient.mapRequest(flow(
|
|
30
|
+
HttpClientRequest.prependUrl("https://jsonplaceholder.typicode.com"),
|
|
31
|
+
HttpClientRequest.acceptJson
|
|
32
|
+
)),
|
|
33
|
+
// Fail if the response status is not 2xx
|
|
34
|
+
HttpClient.filterStatusOk,
|
|
35
|
+
// Retry transient errors (network issues, 5xx responses) with an
|
|
36
|
+
// exponential backoff.
|
|
37
|
+
//
|
|
38
|
+
// See the schedule documentation for more complex retry strategies.
|
|
39
|
+
HttpClient.retryTransient({
|
|
40
|
+
schedule: Schedule.exponential(100),
|
|
41
|
+
times: 3
|
|
42
|
+
})
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const allTodos = client.get("/todos").pipe(
|
|
46
|
+
Effect.flatMap(HttpClientResponse.schemaBodyJson(Schema.Array(Todo))),
|
|
47
|
+
Effect.mapError((cause) => new JsonPlaceholderError({ cause })),
|
|
48
|
+
Effect.withSpan("JsonPlaceholder.allTodos")
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
// Use the HttpClient to fetch a todo item by id, and decode the response
|
|
52
|
+
// using the Todo schema.
|
|
53
|
+
const getTodo = Effect.fn("JsonPlaceholder.getTodo")(function*(id: number) {
|
|
54
|
+
// Annotate the current span with the id of the todo being fetched, so
|
|
55
|
+
// that it shows up in telemetry for this request.
|
|
56
|
+
yield* Effect.annotateCurrentSpan({ id })
|
|
57
|
+
|
|
58
|
+
const todo = yield* client.get(`/todos/${id}`, {
|
|
59
|
+
// You can pass additional options to individual requests.
|
|
60
|
+
// There are options for query parameters, request body, headers, and
|
|
61
|
+
// more.
|
|
62
|
+
urlParams: { format: "json" }
|
|
63
|
+
}).pipe(
|
|
64
|
+
Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo)),
|
|
65
|
+
Effect.mapError((cause) => new JsonPlaceholderError({ cause }))
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
return todo
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// You can use the HttpClientRequest module to build up more complex
|
|
72
|
+
// requests:
|
|
73
|
+
const createTodo = Effect.fn("JsonPlaceholder.createTodo")(function*(todo: Omit<Todo, "id">) {
|
|
74
|
+
yield* Effect.annotateCurrentSpan({ title: todo.title })
|
|
75
|
+
|
|
76
|
+
const createdTodo = yield* HttpClientRequest.post("/todos").pipe(
|
|
77
|
+
// The HttpClientRequest module has many helper functions for building requests.
|
|
78
|
+
HttpClientRequest.setUrlParams({ format: "json" }),
|
|
79
|
+
HttpClientRequest.bodyJsonUnsafe(todo),
|
|
80
|
+
client.execute,
|
|
81
|
+
Effect.flatMap(HttpClientResponse.schemaBodyJson(Todo)),
|
|
82
|
+
Effect.mapError((cause) => new JsonPlaceholderError({ cause }))
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return createdTodo
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
return JsonPlaceholder.of({
|
|
89
|
+
allTodos,
|
|
90
|
+
getTodo,
|
|
91
|
+
createTodo
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
).pipe(
|
|
95
|
+
// Provide the fetch-based HttpClient implementation
|
|
96
|
+
Layer.provide(FetchHttpClient.layer)
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class JsonPlaceholderError extends Schema.TaggedError<JsonPlaceholderError>()("JsonPlaceholderError", {
|
|
101
|
+
cause: Schema.Defect()
|
|
102
|
+
}) {}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @title Getting started with HttpApi
|
|
3
|
+
*
|
|
4
|
+
* Define a schema-first API, implement handlers, secure endpoints with
|
|
5
|
+
* middleware, serve it over HTTP, and call it using a generated typed client.
|
|
6
|
+
*/
|
|
7
|
+
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
|
|
8
|
+
import { Context, Effect, flow, Layer, Schedule } from "effect"
|
|
9
|
+
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/http"
|
|
10
|
+
import { HttpApiBuilder, HttpApiClient, HttpApiMiddleware, HttpApiScalar } from "effect/http-api"
|
|
11
|
+
import { createServer } from "node:http"
|
|
12
|
+
// Api definitions should **always** be seperate from the server implementation,
|
|
13
|
+
// so that they can be shared between the server and client without leaking
|
|
14
|
+
// server code into clients.
|
|
15
|
+
// Ideally, the would use a seperate package in a monorepo.
|
|
16
|
+
import { Api } from "./fixtures/api/Api.ts"
|
|
17
|
+
import { Authorization } from "./fixtures/api/Authorization.ts"
|
|
18
|
+
import { UsersApiHandlers } from "./fixtures/server/Users/http.ts"
|
|
19
|
+
|
|
20
|
+
// This walkthrough focuses on runtime wiring and typed client usage.
|
|
21
|
+
// See the fixture files for the API schemas, endpoint definitions and handlers:
|
|
22
|
+
|
|
23
|
+
const SystemApiHandlers = HttpApiBuilder.group(
|
|
24
|
+
Api,
|
|
25
|
+
"system",
|
|
26
|
+
Effect.fn(function*(handlers) {
|
|
27
|
+
return handlers.handleAll({
|
|
28
|
+
health: () => Effect.void
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const ApiRoutes = HttpApiBuilder.layer(Api, {
|
|
34
|
+
openapiPath: "/openapi.json"
|
|
35
|
+
}).pipe(
|
|
36
|
+
// Provide all the handler Layers for the API.
|
|
37
|
+
Layer.provide([UsersApiHandlers, SystemApiHandlers])
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
// Define a /docs route that serves scalar documentation
|
|
41
|
+
const DocsRoute = HttpApiScalar.layer(Api, {
|
|
42
|
+
path: "/docs"
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// Merge all the http routes together
|
|
46
|
+
const AllRoutes = Layer.mergeAll(ApiRoutes, DocsRoute)
|
|
47
|
+
|
|
48
|
+
// Create an HTTP server Layer that serves the API routes.
|
|
49
|
+
//
|
|
50
|
+
// Here we are using the NodeHttpServer, but you could also use the
|
|
51
|
+
// BunHttpServer
|
|
52
|
+
export const HttpServerLayer = HttpRouter.serve(AllRoutes).pipe(
|
|
53
|
+
Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 }))
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
// Then run the server using Layer.launch
|
|
57
|
+
Layer.launch(HttpServerLayer).pipe(
|
|
58
|
+
NodeRuntime.runMain
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
// Or create a web handler, which can be used in serverless environments
|
|
62
|
+
export const { handler, dispose } = HttpRouter.toWebHandler(AllRoutes.pipe(
|
|
63
|
+
Layer.provide(HttpServer.layerServices)
|
|
64
|
+
))
|
|
65
|
+
|
|
66
|
+
// -----------------
|
|
67
|
+
// Client side setup
|
|
68
|
+
// -----------------
|
|
69
|
+
|
|
70
|
+
export const AuthorizationClient = HttpApiMiddleware.layerClient(
|
|
71
|
+
Authorization,
|
|
72
|
+
Effect.fn(function*({ next, request }) {
|
|
73
|
+
// Here you can modify the request and pass it down the middleware chain.
|
|
74
|
+
// This is where you would add authentication tokens, custom headers, etc.
|
|
75
|
+
// For this example, we just add a hardcoded bearer token to all requests.
|
|
76
|
+
return yield* next(HttpClientRequest.bearerToken(request, "dev-token"))
|
|
77
|
+
})
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
// Define the HttpApiClient service, which will be used to make requests to the
|
|
81
|
+
// API.
|
|
82
|
+
export class ApiClient extends Context.Service<ApiClient, HttpApiClient.ForApi<typeof Api>>()("acme/ApiClient") {
|
|
83
|
+
static readonly layer = Layer.effect(
|
|
84
|
+
ApiClient,
|
|
85
|
+
HttpApiClient.make(Api, {
|
|
86
|
+
// Use transformClient to apply middleware to the generated client. This
|
|
87
|
+
// is useful for settings the base url and applying retry policies.
|
|
88
|
+
transformClient: (client) =>
|
|
89
|
+
client.pipe(
|
|
90
|
+
HttpClient.mapRequest(flow(
|
|
91
|
+
HttpClientRequest.prependUrl("http://localhost:3000")
|
|
92
|
+
)),
|
|
93
|
+
HttpClient.retryTransient({
|
|
94
|
+
schedule: Schedule.exponential(100),
|
|
95
|
+
times: 3
|
|
96
|
+
})
|
|
97
|
+
)
|
|
98
|
+
})
|
|
99
|
+
).pipe(
|
|
100
|
+
// Provide the client implementation of the Authorization middleware, which
|
|
101
|
+
// is required.
|
|
102
|
+
Layer.provide(AuthorizationClient),
|
|
103
|
+
// Supply a HttpClient implementation to use for making requests. Here we
|
|
104
|
+
// use the FetchHttpClient, but you could also use the NodeHttpClient or
|
|
105
|
+
// BunHttpClient.
|
|
106
|
+
Layer.provide(FetchHttpClient.layer)
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The generated client mirrors your API definition, so renames and schema
|
|
111
|
+
// changes are checked end-to-end at compile time.
|
|
112
|
+
export const callApi = Effect.gen(function*() {
|
|
113
|
+
const client = yield* ApiClient
|
|
114
|
+
|
|
115
|
+
yield* client.health()
|
|
116
|
+
}).pipe(
|
|
117
|
+
Effect.provide(ApiClient.layer)
|
|
118
|
+
)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @title Testing HttpApi implementations
|
|
3
|
+
*
|
|
4
|
+
* Test handlers through an in-memory typed client with `HttpApiTest`, without
|
|
5
|
+
* starting an HTTP server or touching a real database.
|
|
6
|
+
*/
|
|
7
|
+
import { assert, layer } from "@effect/vitest"
|
|
8
|
+
import { Effect, Layer } from "effect"
|
|
9
|
+
import { HttpClientRequest, HttpServer } from "effect/http"
|
|
10
|
+
import { HttpApiMiddleware, HttpApiTest } from "effect/http-api"
|
|
11
|
+
import { Api } from "./fixtures/api/Api.ts"
|
|
12
|
+
import { Authorization } from "./fixtures/api/Authorization.ts"
|
|
13
|
+
import { UserId } from "./fixtures/domain/User.ts"
|
|
14
|
+
import { AuthorizationLayer } from "./fixtures/server/Authorization.ts"
|
|
15
|
+
import { Users } from "./fixtures/server/Users.ts"
|
|
16
|
+
import { UsersApiHandlersNoDeps } from "./fixtures/server/Users/http.ts"
|
|
17
|
+
|
|
18
|
+
// Provide the handlers with the in-memory `Users` implementation, so the full
|
|
19
|
+
// HTTP pipeline is exercised without any SQL. The Authorization middleware is
|
|
20
|
+
// provided with `Layer.provideMerge`, because the HTTP pipeline also resolves
|
|
21
|
+
// it when the routes are built.
|
|
22
|
+
const HandlersLayer = UsersApiHandlersNoDeps.pipe(
|
|
23
|
+
Layer.provide(Users.layerMemory),
|
|
24
|
+
Layer.provideMerge(AuthorizationLayer)
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
// The client-side Authorization middleware supplies the bearer token.
|
|
28
|
+
// Providing different middleware implementations lets the tests cover both
|
|
29
|
+
// authorized and unauthorized requests.
|
|
30
|
+
const AuthorizationMiddlewareGood = HttpApiMiddleware.layerClient(
|
|
31
|
+
Authorization,
|
|
32
|
+
({ next, request }) => next(HttpClientRequest.bearerToken(request, "dev-token"))
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const AuthorizationMiddlewareBad = HttpApiMiddleware.layerClient(
|
|
36
|
+
Authorization,
|
|
37
|
+
// Forward the request without attaching a token
|
|
38
|
+
({ next, request }) => next(request)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
// `HttpApiTest.groups` builds a typed client wired directly to the handlers of
|
|
42
|
+
// the selected groups, using the same request encoding, routing, and response
|
|
43
|
+
// decoding as a real server.
|
|
44
|
+
const makeClient = HttpApiTest.groups(Api, ["users"])
|
|
45
|
+
|
|
46
|
+
// `HttpServer.layerServices` provides the platform services the HTTP pipeline
|
|
47
|
+
// needs in tests.
|
|
48
|
+
layer(Layer.mergeAll(HandlersLayer, HttpServer.layerServices))("UsersApi", (it) => {
|
|
49
|
+
it.effect("lists, fetches, and creates users", () =>
|
|
50
|
+
Effect.gen(function*() {
|
|
51
|
+
const client = yield* makeClient
|
|
52
|
+
|
|
53
|
+
const created = yield* client.users.create({
|
|
54
|
+
payload: { name: "Alice", email: "alice@acme.dev" }
|
|
55
|
+
})
|
|
56
|
+
assert.strictEqual(created.name, "Alice")
|
|
57
|
+
|
|
58
|
+
const fetched = yield* client.users.getById({
|
|
59
|
+
params: { id: created.id }
|
|
60
|
+
})
|
|
61
|
+
assert.deepStrictEqual(fetched, created)
|
|
62
|
+
|
|
63
|
+
const all = yield* client.users.list({ query: {} })
|
|
64
|
+
assert.isTrue(all.some((user) => user.id === created.id))
|
|
65
|
+
}).pipe(Effect.provide(AuthorizationMiddlewareGood)))
|
|
66
|
+
|
|
67
|
+
it.effect("returns a 404 for a missing user", () =>
|
|
68
|
+
Effect.gen(function*() {
|
|
69
|
+
const client = yield* makeClient
|
|
70
|
+
|
|
71
|
+
// Use Effect.flip to assert on the error channel
|
|
72
|
+
const error = yield* client.users.getById({
|
|
73
|
+
params: { id: UserId.make("019845e1-682f-4b02-a706-3b2422d13aec") }
|
|
74
|
+
}).pipe(Effect.flip)
|
|
75
|
+
assert.strictEqual(error._tag, "UserNotFound")
|
|
76
|
+
}).pipe(Effect.provide(AuthorizationMiddlewareGood)))
|
|
77
|
+
|
|
78
|
+
it.effect("rejects requests without a valid bearer token", () =>
|
|
79
|
+
Effect.gen(function*() {
|
|
80
|
+
const client = yield* makeClient
|
|
81
|
+
|
|
82
|
+
const error = yield* client.users.list({ query: {} }).pipe(Effect.flip)
|
|
83
|
+
assert.strictEqual(error._tag, "Unauthorized")
|
|
84
|
+
}).pipe(Effect.provide(AuthorizationMiddlewareBad)))
|
|
85
|
+
|
|
86
|
+
it.effect("rejects requests with an invalid bearer token", () =>
|
|
87
|
+
Effect.gen(function*() {
|
|
88
|
+
const client = yield* makeClient
|
|
89
|
+
|
|
90
|
+
const error = yield* client.users.getById({
|
|
91
|
+
params: { id: UserId.make("019845e1-682f-4b02-a706-3b2422d13aec") }
|
|
92
|
+
}).pipe(Effect.flip)
|
|
93
|
+
assert.strictEqual(error._tag, "Unauthorized")
|
|
94
|
+
}).pipe(
|
|
95
|
+
Effect.provide(HttpApiMiddleware.layerClient(
|
|
96
|
+
Authorization,
|
|
97
|
+
({ next, request }) => next(HttpClientRequest.bearerToken(request, "wrong-token"))
|
|
98
|
+
))
|
|
99
|
+
))
|
|
100
|
+
})
|