Repository navigation
Expand file tree
/
Copy pathbackends.ts
More file actions
154 lines (133 loc) · 4.52 KB
/
Copy pathbackends.ts
File metadata and controls
154 lines (133 loc) · 4.52 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
import * as Config from "effect/Config";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as FetchHttpClient from "effect/http/FetchHttpClient";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import * as Fetch from "@monarcode/effetch";
import { bearer, logger, retry, timeout } from "@monarcode/effetch/plugins";
// Two backends, each with its own base URL, error shape and plugins: one
// client per backend, each in its own service.
const User = Schema.Struct({ id: Schema.String, name: Schema.String });
const Balance = Schema.Struct({
amount: Schema.Finite,
currency: Schema.String,
});
const Transfer = Schema.Struct({ to: Schema.String, amount: Schema.Finite });
const Receipt = Schema.Struct({ id: Schema.String });
const UsersError = Schema.Struct({ code: Schema.String });
const WalletError = Schema.Struct({
error: Schema.String,
reason: Schema.String,
});
const session: { accessToken: string | undefined } = {
accessToken: undefined,
};
// Plugins are plain values, so both clients can share one token source.
const auth = bearer({ token: () => session.accessToken });
// `ctx.route` is a method and path, with no host: both backends could have a
// `GET /health`. A plugin shared across backends takes the backend's name,
// and each client gets its own instance.
const reportErrors = (backend: string) =>
Fetch.plugin({
name: "reportErrors",
onError: (error, ctx) =>
Effect.logWarning(`${backend} ${ctx.route} failed`, error),
});
const makeUsersApi = Effect.gen(function* () {
const client = yield* Fetch.make({
baseUrl: Config.String("USERS_API_URL"),
defaultError: UsersError,
plugins: [logger(), reportErrors("users"), retry(), auth],
});
return {
// The client itself, for calls that aren't endpoints.
client,
me: client.endpoint("GET /me", { output: User }),
getUser: client.endpoint("GET /users/:id", { output: User }),
};
});
export class UsersApi extends Context.Service<
UsersApi,
Effect.Success<typeof makeUsersApi>
>()("UsersApi") {}
const makeWalletApi = Effect.gen(function* () {
const client = yield* Fetch.make({
baseUrl: Config.String("WALLET_API_URL"),
defaultError: WalletError,
plugins: [
logger(),
reportErrors("wallet"),
timeout({ duration: "10 seconds" }),
auth,
],
});
return {
client,
balance: client.endpoint("GET /wallets/:userId/balance", {
output: Balance,
}),
transfer: client.endpoint("POST /transfers", {
input: Transfer,
output: Receipt,
plugins: { timeout: { duration: "30 seconds" } },
}),
};
});
export class WalletApi extends Context.Service<
WalletApi,
Effect.Success<typeof makeWalletApi>
>()("WalletApi") {}
// One HttpClient for both.
export const ApisLive = Layer.mergeAll(
Layer.effect(UsersApi)(makeUsersApi),
Layer.effect(WalletApi)(makeWalletApi),
).pipe(Layer.provide(FetchHttpClient.layer));
// Each backend's errors are caught next to its calls, where `HttpError.body`
// has that backend's error shape.
const currentUser = Effect.gen(function* () {
const users = yield* UsersApi;
return yield* users.me();
}).pipe(
Effect.catchTag("HttpError", (error) =>
Effect.fail(`users: ${error.body?.code ?? `HTTP ${error.status}`}`),
),
);
const sendHalf = (userId: string, to: string) =>
Effect.gen(function* () {
const wallet = yield* WalletApi;
const { amount } = yield* wallet.balance({ params: { userId } });
return yield* wallet.transfer({ body: { to, amount: amount / 2 } });
}).pipe(
Effect.catchTag("HttpError", (error) =>
Effect.fail(`wallet: ${error.body?.reason ?? `HTTP ${error.status}`}`),
),
);
// A one-off call to the users backend, with the client rather than an
// endpoint.
const recordActivity = (userId: string, receiptId: string) =>
Effect.gen(function* () {
const users = yield* UsersApi;
yield* users.client.post("/users/:id/activity", {
params: { id: userId },
body: { type: "transfer", receipt: receiptId },
});
}).pipe(
Effect.catchTag("HttpError", (error) =>
Effect.fail(`users: ${error.body?.code ?? `HTTP ${error.status}`}`),
),
);
export const program = (to: string) =>
Effect.gen(function* () {
const user = yield* currentUser;
const receipt = yield* sendHalf(user.id, to);
yield* recordActivity(user.id, receipt.id);
return receipt;
}).pipe(
Effect.catchTags({
NetworkError: () => Effect.fail("You appear to be offline"),
DecodeError: (error) =>
Effect.fail(`Unexpected response: ${error.message}`),
}),
);
export const main = (to: string) => program(to).pipe(Effect.provide(ApisLive));