| 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 | */ |
| 13 | import * as HttpApiEndpoint from "../http-api/HttpApiEndpoint.ts" |
| 14 | import * as HttpApiGroup from "../http-api/HttpApiGroup.ts" |
| 15 | import * as Rpc from "../rpc/Rpc.ts" |
| 16 | import * as RpcGroup from "../rpc/RpcGroup.ts" |
| 17 | import * as Schema from "../Schema.ts" |
| 18 | import { AlreadyProcessingMessage, EntityNotAssignedToRunner, MailboxFull, PersistenceError } from "./ClusterError.ts" |
| 19 | import type * as Entity from "./Entity.ts" |
| 20 | import type { EntityId } from "./EntityId.ts" |
| 21 | |
| 22 | const clientErrors = [ |
| 23 | MailboxFull, |
| 24 | AlreadyProcessingMessage, |
| 25 | PersistenceError |
| 26 | ] as const |
| 27 | |
| 28 | const 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 | */ |
| 66 | export 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 | */ |
| 103 | export 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 | |
| 144 | const 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 | */ |
| 188 | export 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 |
| 217 | const 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 | */ |
| 236 | export 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 |