1/**
2 * Derives RPC and HTTP API surfaces from clustered entities.
3 *
4 * The generated APIs let callers use ordinary RPC clients or HTTP routes while
5 * the cluster runtime still locates and delivers messages to the entity
6 * instance identified by `entityId`. Each generated operation keeps the
7 * original payload and success schema, adds cluster client errors, and creates
8 * a discard variant for fire-and-forget delivery.
9 *
10 * @stability unstable
11 * @since 4.0.0
12 */
13import * as HttpApiEndpoint from "../http-api/HttpApiEndpoint.ts"
14import * as HttpApiGroup from "../http-api/HttpApiGroup.ts"
15import * as Rpc from "../rpc/Rpc.ts"
16import * as RpcGroup from "../rpc/RpcGroup.ts"
17import * as Schema from "../Schema.ts"
18import { AlreadyProcessingMessage, EntityNotAssignedToRunner, MailboxFull, PersistenceError } from "./ClusterError.ts"
19import type * as Entity from "./Entity.ts"
20import type { EntityId } from "./EntityId.ts"
21
22const clientErrors = [
23 MailboxFull,
24 AlreadyProcessingMessage,
25 PersistenceError
26] as const
27
28const requestErrors = [
29 ...clientErrors,
30 EntityNotAssignedToRunner
31] as const
32
33/**
34 * Derives an `RpcGroup` from an `Entity`.
35 *
36 * **Example** (Deriving RPC endpoints from an entity)
37 *
38 * ```ts import.meta.vitest
39 * import { Layer, Schema } from "effect"
40 * import { ClusterSchema, Entity, EntityProxy, EntityProxyServer } from "effect/cluster"
41 * import { Rpc, RpcServer } from "effect/rpc"
42 *
43 * export const Counter = Entity.make("Counter", [
44 * Rpc.make("Increment", {
45 * payload: { id: Schema.String, amount: Schema.Number },
46 * primaryKey: ({ id }) => id,
47 * success: Schema.Number
48 * })
49 * ]).annotateRpcs(ClusterSchema.Persisted, true)
50 *
51 * // Use EntityProxy.toRpcGroup to create a `RpcGroup` from the Counter entity
52 * export class MyRpcs extends EntityProxy.toRpcGroup(Counter) {}
53 *
54 * // Use EntityProxyServer.layerRpcHandlers to create a layer that implements
55 * // the rpc handlers
56 * const RpcServerLayer = RpcServer.layer(MyRpcs).pipe(
57 * Layer.provide(EntityProxyServer.layerRpcHandlers(Counter))
58 * )
59 * const result = [MyRpcs.requests.size, Layer.isLayer(RpcServerLayer)] // => [2, true]
60 * ```
61 *
62 * @stability unstable
63 * @category constructors
64 * @since 4.0.0
65 */
66export const toRpcGroup = <Type extends string, Rpcs extends Rpc.Any>(
67 entity: Entity.Entity<Type, Rpcs>
68): RpcGroup.RpcGroup<ConvertRpcs<Rpcs, Type>> => {
69 const rpcs: Array<Rpc.Any> = []
70 for (const parentRpc_ of entity.protocol.requests.values()) {
71 const parentRpc = parentRpc_ as any as Rpc.AnyWithProps
72 const payloadSchema = Schema.Struct({
73 entityId: Schema.String,
74 payload: parentRpc.payloadSchema
75 })
76 const rpc = Rpc.make(`${entity.type}.${parentRpc._tag}`, {
77 payload: payloadSchema,
78 error: Schema.Union([parentRpc.errorSchema, ...requestErrors]),
79 success: parentRpc.successSchema
80 }).annotateMerge(parentRpc.annotations)
81 const rpcDiscard = Rpc.make(`${entity.type}.${parentRpc._tag}Discard`, {
82 payload: payloadSchema,
83 error: Schema.Union(clientErrors)
84 }).annotateMerge(parentRpc.annotations)
85 rpcs.push(rpc, rpcDiscard)
86 }
87 return RpcGroup.make(...rpcs) as any as RpcGroup.RpcGroup<ConvertRpcs<Rpcs, Type>>
88}
89
90/**
91 * Type-level conversion used by `toRpcGroup`.
92 *
93 * **Details**
94 *
95 * For each entity RPC, this creates a prefixed request RPC and a discard RPC
96 * whose payload includes `entityId`, and whose errors include cluster client
97 * errors.
98 *
99 * @stability unstable
100 * @category converting
101 * @since 4.0.0
102 */
103export type ConvertRpcs<Rpcs extends Rpc.Any, Prefix extends string> = Rpcs extends Rpc.Rpc<
104 infer _Tag,
105 infer _Payload,
106 infer _Success,
107 infer _Error,
108 infer _Middleware,
109 infer _Requires
110> ?
111 | Rpc.Rpc<
112 `${Prefix}.${_Tag}`,
113 Schema.Struct<{
114 entityId: typeof Schema.String
115 payload: _Payload
116 }>,
117 _Success,
118 Schema.Codec<
119 _Error["Type"] | MailboxFull | AlreadyProcessingMessage | PersistenceError | EntityNotAssignedToRunner,
120 | _Error["Encoded"]
121 | typeof MailboxFull["Encoded"]
122 | typeof AlreadyProcessingMessage["Encoded"]
123 | typeof PersistenceError["Encoded"]
124 | typeof EntityNotAssignedToRunner["Encoded"],
125 _Error["DecodingServices"],
126 _Error["EncodingServices"]
127 >
128 >
129 | Rpc.Rpc<
130 `${Prefix}.${_Tag}Discard`,
131 Schema.Struct<{
132 entityId: typeof Schema.String
133 payload: _Payload
134 }>,
135 typeof Schema.Void,
136 Schema.Union<[
137 typeof MailboxFull,
138 typeof AlreadyProcessingMessage,
139 typeof PersistenceError
140 ]>
141 >
142 : never
143
144const entityIdPath = {
145 entityId: Schema.String
146}
147
148/**
149 * Derives an `HttpApiGroup` from an `Entity`.
150 *
151 * **Example** (Deriving HTTP API endpoints from an entity)
152 *
153 * ```ts import.meta.vitest
154 * import { Layer, Schema } from "effect"
155 * import { ClusterSchema, Entity, EntityProxy, EntityProxyServer } from "effect/cluster"
156 * import { HttpApi, HttpApiBuilder } from "effect/http-api"
157 * import { Rpc } from "effect/rpc"
158 *
159 * export const Counter = Entity.make("Counter", [
160 * Rpc.make("Increment", {
161 * payload: { id: Schema.String, amount: Schema.Number },
162 * primaryKey: ({ id }) => id,
163 * success: Schema.Number
164 * })
165 * ]).annotateRpcs(ClusterSchema.Persisted, true)
166 *
167 * // Use EntityProxy.toHttpApiGroup to create a `HttpApiGroup` from the
168 * // Counter entity
169 * export class MyApi extends HttpApi.make("api")
170 * .add(
171 * EntityProxy.toHttpApiGroup("counter", Counter)
172 * .prefix("/counter")
173 * )
174 * {}
175 *
176 * // Use EntityProxyServer.layerHttpApi to create a layer that implements
177 * // the handlers for the HttpApiGroup
178 * const ApiLayer = HttpApiBuilder.layer(MyApi).pipe(
179 * Layer.provide(EntityProxyServer.layerHttpApi(MyApi, "counter", Counter))
180 * )
181 * const result = [Object.keys(MyApi.groups.counter.endpoints).length, Layer.isLayer(ApiLayer)] // => [2, true]
182 * ```
183 *
184 * @stability unstable
185 * @category constructors
186 * @since 4.0.0
187 */
188export const toHttpApiGroup = <const Name extends string, Type extends string, Rpcs extends Rpc.Any>(
189 name: Name,
190 entity: Entity.Entity<Type, Rpcs>
191): HttpApiGroup.HttpApiGroup<Name, ConvertHttpApi<Rpcs>> => {
192 let group = HttpApiGroup.make(name)
193 for (const parentRpc_ of entity.protocol.requests.values()) {
194 const parentRpc = parentRpc_ as any as Rpc.AnyWithProps
195 const endpoint = HttpApiEndpoint.post(parentRpc._tag, `/${tagToPath(parentRpc._tag)}/:entityId`, {
196 params: entityIdPath,
197 payload: parentRpc.payloadSchema,
198 success: parentRpc.successSchema,
199 error: [parentRpc.errorSchema, ...requestErrors]
200 }).annotateMerge(parentRpc.annotations)
201 const endpointDiscard = HttpApiEndpoint.post(
202 `${parentRpc._tag}Discard`,
203 `/${tagToPath(parentRpc._tag)}/:entityId/discard`,
204 {
205 params: entityIdPath,
206 payload: parentRpc.payloadSchema,
207 error: clientErrors
208 }
209 ).annotateMerge(parentRpc.annotations)
210
211 group = group.add(endpoint).add(endpointDiscard) as any
212 }
213 return group as any as HttpApiGroup.HttpApiGroup<Name, ConvertHttpApi<Rpcs>>
214}
215
216// TODO: type level equivalent
217const tagToPath = (tag: string): string =>
218 tag
219 // .replace(/[^a-zA-Z0-9]+/g, "-") // Replace non-alphanumeric characters with hyphen
220 // .replace(/([a-z])([A-Z])/g, "$1-$2") // Insert hyphen before uppercase letters
221 .toLowerCase()
222
223/**
224 * Type-level conversion used by `toHttpApiGroup`.
225 *
226 * **Details**
227 *
228 * For each entity RPC, this creates a POST endpoint at `/<tag>/:entityId` and a
229 * discard endpoint at `/<tag>/:entityId/discard`, including cluster client
230 * errors.
231 *
232 * @stability unstable
233 * @category converting
234 * @since 4.0.0
235 */
236export type ConvertHttpApi<Rpcs extends Rpc.Any> = Rpcs extends Rpc.Rpc<
237 infer _Tag,
238 infer _Payload,
239 infer _Success,
240 infer _Error,
241 infer _Middleware,
242 infer _Requires
243> ?
244 | HttpApiEndpoint.HttpApiEndpoint<
245 _Tag,
246 "POST",
247 `/${Lowercase<_Tag>}/:entityId`,
248 Schema.Struct<{ entityId: typeof EntityId }>,
249 never,
250 _Payload,
251 never,
252 _Success,
253 | _Error
254 | typeof MailboxFull
255 | typeof AlreadyProcessingMessage
256 | typeof PersistenceError
257 | typeof EntityNotAssignedToRunner
258 >
259 | HttpApiEndpoint.HttpApiEndpoint<
260 `${_Tag}Discard`,
261 "POST",
262 `/${Lowercase<_Tag>}/:entityId/discard`,
263 Schema.Struct<{ entityId: typeof EntityId }>,
264 never,
265 _Payload,
266 never,
267 Schema.Void,
268 typeof MailboxFull | typeof AlreadyProcessingMessage | typeof PersistenceError
269 >
270 : never