diff --git a/.changeset/ten-laws-join.md b/.changeset/ten-laws-join.md new file mode 100644 index 0000000000..8a95cb483a --- /dev/null +++ b/.changeset/ten-laws-join.md @@ -0,0 +1,5 @@ +--- +"@effect-app/infra": minor +--- + +support middleware `requires` and simplified api diff --git a/.vscode/settings.json b/.vscode/settings.json index fdc73f3d26..14cf11524a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,7 +7,7 @@ // would prefer on in frontend, so might want to use a specialised frontend config.. "volar.updateImportsOnFileMove.enabled": false, "typescript.preferences.includePackageJsonAutoImports": "on", - "typescript.preferences.autoImportFileExcludePatterns": [w + "typescript.preferences.autoImportFileExcludePatterns": [ "node_modules/@sentry/node", "node_modules/vitest/dist", "node_modules/@azure/cosmos", diff --git a/packages/infra/src/api/layerUtils.ts b/packages/infra/src/api/layerUtils.ts index 7973e38f6f..12d1e0ca64 100644 --- a/packages/infra/src/api/layerUtils.ts +++ b/packages/infra/src/api/layerUtils.ts @@ -7,19 +7,19 @@ export namespace LayerUtils { NonEmptyReadonlyArray ? { [k in keyof Layers]: Layer.Layer.Success }[number] - : never + : Layer.Layer.Success export type GetLayersContext> = Layers extends NonEmptyReadonlyArray ? { [k in keyof Layers]: Layer.Layer.Context }[number] - : never + : Layer.Layer.Context export type GetLayersError> = Layers extends NonEmptyReadonlyArray ? { [k in keyof Layers]: Layer.Layer.Error }[number] - : never + : Layer.Layer.Error } export type ContextTagWithDefault = diff --git a/packages/infra/src/api/routing.ts b/packages/infra/src/api/routing.ts index d54bcb563d..587f08f952 100644 --- a/packages/infra/src/api/routing.ts +++ b/packages/infra/src/api/routing.ts @@ -4,7 +4,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { determineMethod, isCommand } from "@effect-app/infra/api/routing/utils" import { Rpc, RpcGroup, RpcServer } from "@effect/rpc" -import { type Array, Duration, Effect, Layer, type NonEmptyReadonlyArray, Predicate, Request, S, Schedule, Schema, type Scope } from "effect-app" +import { type Array, Effect, Layer, type NonEmptyReadonlyArray, Predicate, S, Schedule, Schema, type Scope } from "effect-app" import type { GetEffectContext, GetEffectError, RPCContextMap } from "effect-app/client/req" import { type HttpHeaders, HttpRouter } from "effect-app/http" import { typedKeysOf, typedValuesOf } from "effect-app/utils" @@ -819,11 +819,3 @@ export type MakeHandlers> = Make exte * @since 3.9.0 */ export type MakeDepsOut = Contravariant.Type[Layer.LayerTypeId]["_ROut"]> - -export const RequestCacheLayers = Layer.mergeAll( - Layer.setRequestCache( - Request.makeCache({ capacity: 500, timeToLive: Duration.hours(8) }) - ), - Layer.setRequestCaching(true), - Layer.setRequestBatching(true) -) diff --git a/packages/infra/src/api/routing/middleware/ContextProvider.ts b/packages/infra/src/api/routing/middleware/ContextProvider.ts index 06d801a07a..b9078a21f4 100644 --- a/packages/infra/src/api/routing/middleware/ContextProvider.ts +++ b/packages/infra/src/api/routing/middleware/ContextProvider.ts @@ -87,9 +87,9 @@ export const mergeContextProviders = < effect: Effect.Effect< Effect.Effect< // we need to merge all contexts into one - Context.Context>>>, + Context.Context>>>, never, - EffectGenUtils.Context> + EffectGenUtils.Context> >, LayerUtils.GetLayersError<{ [K in keyof TDeps]: TDeps[K]["Default"] }>, LayerUtils.GetLayersSuccess<{ [K in keyof TDeps]: TDeps[K]["Default"] }> @@ -174,13 +174,13 @@ export const MergedContextProvider = < ContextProviderId, Effect.Effect< // we need to merge all contexts into one - Context.Context>>>, + Context.Context>>>, never, - EffectGenUtils.Context> + EffectGenUtils.Context> >, LayerUtils.GetLayersError<{ [K in keyof TDeps]: TDeps[K]["Default"] }>, | Exclude< - Tag.Service, + Tag.Identifier, LayerUtils.GetLayersSuccess<{ [K in keyof TDeps]: TDeps[K]["Default"] }> > | LayerUtils.GetLayersContext<{ [K in keyof TDeps]: TDeps[K]["Default"] }> diff --git a/packages/infra/src/api/routing/middleware/DynamicMiddleware.ts b/packages/infra/src/api/routing/middleware/DynamicMiddleware.ts index 134d909e0e..dee051625c 100644 --- a/packages/infra/src/api/routing/middleware/DynamicMiddleware.ts +++ b/packages/infra/src/api/routing/middleware/DynamicMiddleware.ts @@ -94,10 +94,10 @@ export type RequestContextMapProvider, // what services will the middleware provide dynamically to the next, or raise errors. DynamicMiddlewareProviders extends RequestContextMapProvider, // how to resolve the dynamic middleware - GenericMiddlewareProviders extends NonEmptyReadonlyArray, + GenericMiddlewareProviders extends ReadonlyArray, MakeMiddlewareE, // what the middleware construction can fail with MakeMiddlewareR, // what the middleware requires to be constructed - MiddlewareDependencies extends NonEmptyReadonlyArray // layers provided for the middleware to be constructed + MiddlewareDependencies extends ReadonlyArray // layers provided for the middleware to be constructed > { /* dynamic middlewares to be applied based on Request Configuration */ dynamicMiddlewares: DynamicMiddlewareProviders @@ -170,8 +170,8 @@ export const makeMiddleware = >() => < RequestContextProviders extends RequestContextMapProvider, // how to resolve the dynamic middleware - GenericMiddlewareProviders extends NonEmptyReadonlyArray, - MiddlewareDependencies extends NonEmptyReadonlyArray, // layers provided for the middlware to be constructed + GenericMiddlewareProviders extends ReadonlyArray, + MiddlewareDependencies extends ReadonlyArray, // layers provided for the middlware to be constructed MakeMiddlewareE = never, // what the middleware construction can fail with MakeMiddlewareR = never // what the middlware requires to be constructed >( @@ -295,7 +295,7 @@ export const makeMiddlewareBasic = < RequestContextMap extends Record, RequestContextProviders extends RequestContextMapProvider, // how to resolve the dynamic middleware - GenericMiddlewareProviders extends NonEmptyReadonlyArray + GenericMiddlewareProviders extends ReadonlyArray >( make: MiddlewareMake< RequestContextMap, @@ -446,22 +446,22 @@ function makeRpcEffect< } // updated to support Scope.Scope -export interface RpcMiddleware { +export interface RpcMiddleware { (options: { readonly clientId: number readonly rpc: Rpc.AnyWithProps readonly payload: unknown readonly headers: HttpHeaders.Headers - }): Effect.Effect + }): Effect.Effect } -export interface RpcMiddlewareWrap { +export interface RpcMiddlewareWrap { (options: { readonly clientId: number readonly rpc: Rpc.AnyWithProps readonly payload: unknown readonly headers: HttpHeaders.Headers - readonly next: Effect.Effect - }): Effect.Effect + readonly next: Effect.Effect + }): Effect.Effect } type RpcOptionsOriginal = { @@ -484,22 +484,23 @@ type RpcOptionsDynamic = RpcOpt export type Dynamic = Options extends RpcOptionsDynamic ? true : false -export interface RpcMiddlewareDynamic { +export interface RpcMiddlewareDynamic { (options: { readonly config: Config // todo readonly clientId: number readonly rpc: Rpc.AnyWithProps readonly payload: unknown readonly headers: HttpHeaders.Headers - }): Effect.Effect>, E, Scope.Scope> + }): Effect.Effect>, E, Scope.Scope | R> } export interface TagClassDynamicAny> extends Context.Tag { readonly [RpcMiddleware.TypeId]: RpcMiddleware.TypeId - readonly optional: boolean - readonly provides?: Context.Tag | undefined + readonly optional: boolean // TODO: support optional vs required.. with required, we can eliminate the context.. + // readonly provides?: Context.Tag | undefined + readonly requires?: Context.Tag | ContextRepr | undefined readonly failure: Schema.Schema.All readonly requiredForClient: boolean readonly dynamic: RpcDynamic @@ -522,6 +523,18 @@ export declare namespace TagClass { } ? ContextRepr.Identifier : never + /** + * @since 1.0.0 + * @category models + */ + export type Requires = Options extends { + readonly requires: Context.Tag + } ? Context.Tag.Identifier + : Options extends { + readonly requires: ContextRepr + } ? ContextRepr.Identifier + : never + /** * @since 1.0.0 * @category models @@ -592,6 +605,9 @@ export declare namespace TagClass { readonly provides: Options extends { readonly provides: Context.Tag } ? Options["provides"] : Options extends { readonly provides: ContextRepr } ? Options["provides"] : undefined + readonly requires: Options extends { readonly requires: Context.Tag } ? Options["requires"] + : Options extends { readonly requires: ContextRepr } ? Options["requires"] + : undefined readonly dynamic: Options extends RpcOptionsDynamic ? Options["dynamic"] : undefined readonly requiredForClient: RequiredForClient @@ -610,16 +626,19 @@ export interface TagClass< Options, TagClass.Wrap extends true ? RpcMiddlewareWrap< TagClass.Provides, + TagClass.Requires, TagClass.Failure > : Options extends RpcOptionsDynamic ? RpcMiddlewareDynamic< TagClass.Service, TagClass.FailureService, + TagClass.Requires, { [K in Options["dynamic"]["key"]]?: Options["dynamic"]["settings"]["contextActivation"] } > : RpcMiddleware< TagClass.Service, - TagClass.FailureService + TagClass.FailureService, + TagClass.Requires > > {} @@ -636,16 +655,19 @@ export const Tag = () => effect: Effect.Effect< TagClass.Wrap extends true ? RpcMiddlewareWrap< TagClass.Provides, - TagClass.Failure + TagClass.Failure, + TagClass.Requires > : Options extends RpcOptionsDynamic ? RpcMiddlewareDynamic< TagClass.Service, TagClass.FailureService, + TagClass.Requires, { [K in Options["dynamic"]["key"]]?: Options["dynamic"]["settings"]["contextActivation"] } > : RpcMiddleware< TagClass.Service, - TagClass.FailureService + TagClass.FailureService, + TagClass.Requires >, E, R diff --git a/packages/infra/src/api/routing/middleware/dynamic-middleware.ts b/packages/infra/src/api/routing/middleware/dynamic-middleware.ts index 480b2640fc..b791ac78ef 100644 --- a/packages/infra/src/api/routing/middleware/dynamic-middleware.ts +++ b/packages/infra/src/api/routing/middleware/dynamic-middleware.ts @@ -20,13 +20,15 @@ export type ContextWithLayer< & ( | ContextTagWithDefault< Id, - RpcMiddlewareDynamic, + // todo + RpcMiddlewareDynamic, LayerE, LayerR > | ContextTagWithDefault< Id, - RpcMiddlewareDynamic, + // todo + RpcMiddlewareDynamic, LayerE, LayerR > @@ -84,7 +86,7 @@ export const implementMiddleware = > ) => Effect.Effect< Context.Context>, Effect.Error>>, - Effect.Context>> + Effect.Context>> >, never, Tag.Identifier<{ [K in keyof TI]: TI[K] }[keyof TI]> diff --git a/packages/infra/src/api/routing/middleware/generic-middleware.ts b/packages/infra/src/api/routing/middleware/generic-middleware.ts index 7ba09a2385..b9e3d7fb1f 100644 --- a/packages/infra/src/api/routing/middleware/generic-middleware.ts +++ b/packages/infra/src/api/routing/middleware/generic-middleware.ts @@ -18,6 +18,7 @@ export interface TagClassAny extends Context.Tag { readonly [TypeId]: TypeId readonly optional: boolean readonly provides?: Context.Tag | ContextRepr | undefined + readonly requires?: Context.Tag | ContextRepr | undefined readonly failure: Schema.Schema.All readonly requiredForClient: boolean readonly wrap: boolean @@ -38,11 +39,21 @@ export type DynamicMiddlewareMaker = Exclude> | Required + export type ApplyManyServices, R> = + | Exclude }[number]> + | { [K in keyof A]: Required }[number] export type Provided = T extends TagClassAny ? T extends { provides: Context.Tag } ? Context.Tag.Identifier : T extends { provides: ContextRepr } ? ContextRepr.Identifier : never : never + + export type Required = T extends TagClassAny + ? T extends { requires: Context.Tag } ? Context.Tag.Identifier + : T extends { requires: ContextRepr } ? ContextRepr.Identifier + : never + : never } export const genericMiddleware = (i: GenericMiddlewareMaker) => i diff --git a/packages/infra/src/api/routing/middleware/middleware-api.ts b/packages/infra/src/api/routing/middleware/middleware-api.ts index 676ea0d050..7b66a7fdc7 100644 --- a/packages/infra/src/api/routing/middleware/middleware-api.ts +++ b/packages/infra/src/api/routing/middleware/middleware-api.ts @@ -1,4 +1,4 @@ -import { type NonEmptyReadonlyArray } from "effect-app" +import { Array, Either, type NonEmptyArray } from "effect-app" import { type RPCContextMap } from "effect-app/client" import { type DynamicMiddlewareMaker, type GenericMiddlewareMaker, makeMiddleware, type makeMiddlewareBasic, type RequestContextMapProvider } from "../../routing.js" @@ -9,41 +9,128 @@ export const contextMap = () => , Provided extends keyof RequestContext, - Middlewares extends NonEmptyReadonlyArray, - DynamicMiddlewareProviders extends RequestContextMapProvider -> = keyof Omit extends never - ? ReturnType> - : { - addDynamicMiddleware: >>( - ...middlewares: MW - ) => DynamicMiddlewareMakerrsss< + Middlewares extends ReadonlyArray, + DynamicMiddlewareProviders, + // out MiddlewareR = never + MiddlewareR = never +> { + middleware>( + ...mw: MW + ): DynamicMiddlewareMakerrsss< + RequestContext, + Provided, + [...Middlewares, ...MW], + DynamicMiddlewareProviders, + GenericMiddlewareMaker.ApplyManyServices + > +} + +export interface MiddlewareDynamic< + RequestContext extends Record, + Provided extends keyof RequestContext, + Middlewares extends ReadonlyArray, + DynamicMiddlewareProviders, + out MiddlewareR +> { + // TODO: this still allows to mix both types of middleware but with bad typing result + // either have to block it, or implement the support properly. + middleware> | NonEmptyArray>( + ...mw: MW + ): [MW] extends [NonEmptyArray>] ? DynamicMiddlewareMakerrsss< RequestContext, Provided | MW[number]["dynamic"]["key"], Middlewares, & DynamicMiddlewareProviders & { - [K in keyof MW as MW[K] extends DynamicMiddlewareMaker ? MW[K]["dynamic"]["key"] : never]: MW[K] - } + [U in MW[number] as U["dynamic"]["key"]]: U + }, + GenericMiddlewareMaker.ApplyManyServices > - } + : DynamicMiddlewareMakerrsss< + RequestContext, + Provided, + [...Middlewares, ...MW], + DynamicMiddlewareProviders, + GenericMiddlewareMaker.ApplyManyServices + > +} + +type GetDynamicMiddleware> = T extends + RequestContextMapProvider ? T : never + +type DynamicMiddlewareMakerrsss< + RequestContext extends Record, + Provided extends keyof RequestContext = never, + Middlewares extends ReadonlyArray = [], + DynamicMiddlewareProviders = unknown, + MiddlewareR = never +> = keyof Omit extends never ? [MiddlewareR] extends [never] ? + & ReturnType< + typeof makeMiddlewareBasic< + RequestContext, + GetDynamicMiddleware, + Middlewares + > + > + // & { + // MiddlewareR: MiddlewareR + // Provided: Provided + // Middlewares: Middlewares + // DynamicMiddlewareProviders: Simplify + // } + & MiddlewareM< + RequestContext, + Provided, + Middlewares, + DynamicMiddlewareProviders, + MiddlewareR + > + : MiddlewareM< + RequestContext, + Provided, + Middlewares, + DynamicMiddlewareProviders, + MiddlewareR + > + : MiddlewareDynamic< + RequestContext, + Provided, + Middlewares, + DynamicMiddlewareProviders, + MiddlewareR + > export const makeNewMiddleware: < RequestContextMap extends Record ->() => >( - ...genericMiddlewares: Middlewares -) => DynamicMiddlewareMakerrsss = () => (...genericMiddlewares) => { - const dynamicMiddlewares: Record = {} as any +>() => DynamicMiddlewareMakerrsss = () => { const make = makeMiddleware() + let capturedMiddlewares: (DynamicMiddlewareMaker | GenericMiddlewareMaker)[] = [] const it = { - addDynamicMiddleware: (...middlewares: any[]) => { - for (const a of middlewares) { - console.log("Adding dynamic middleware", a, a.dynamic, Object.keys(a)) - dynamicMiddlewares[a.dynamic.key] = a + middleware: (...middlewares: any[]) => { + for (const mw of middlewares) { + capturedMiddlewares = [mw, ...capturedMiddlewares] + if (mw.dynamic) { + console.log("Adding dynamic middleware", mw.key, mw.dynamic.key) + } else { + console.log("Adding generic middleware", mw.key) + } } - return Object.assign(make({ genericMiddlewares: genericMiddlewares as any, dynamicMiddlewares }), it) + const [genericMiddlewares, dyn] = Array.partitionMap( + capturedMiddlewares, + (mw) => + "dynamic" in mw && mw.dynamic + ? Either.right(mw as DynamicMiddlewareMaker) + : Either.left(mw as GenericMiddlewareMaker) + ) + const dynamicMiddlewares = dyn.reduce( + (prev, cur) => ({ ...prev, [cur.dynamic.key]: cur }), + {} as Record + ) + // TODO: support dynamic and generic intertwined. treat them as one + return Object.assign(make({ genericMiddlewares, dynamicMiddlewares }), it) } } return it as any diff --git a/packages/infra/src/api/routing/middleware/middleware.ts b/packages/infra/src/api/routing/middleware/middleware.ts index 4d2d0cd862..93716ba37b 100644 --- a/packages/infra/src/api/routing/middleware/middleware.ts +++ b/packages/infra/src/api/routing/middleware/middleware.ts @@ -1,13 +1,21 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { Cause, Context, Effect, ParseResult } from "effect-app" +import { Cause, Context, Duration, Effect, Layer, ParseResult, Request } from "effect-app" import { pretty } from "effect-app/utils" import { logError, reportError } from "../../../errorReporter.js" import { InfraLogger } from "../../../logger.js" -import { RequestCacheLayers, Tag } from "../../routing.js" +import { Tag } from "../middleware.js" const logRequestError = logError("Request") const reportRequestError = reportError("Request") +export const RequestCacheLayers = Layer.mergeAll( + Layer.setRequestCache( + Request.makeCache({ capacity: 500, timeToLive: Duration.hours(8) }) + ), + Layer.setRequestCaching(true), + Layer.setRequestBatching(true) +) + export class DevMode extends Context.Reference()("DevMode", { defaultValue: () => false }) {} export class RequestCacheMiddleware extends Tag()("RequestCacheMiddleware", { wrap: true })({ diff --git a/packages/infra/test/contextProvider.test.ts b/packages/infra/test/contextProvider.test.ts index 7391d77a19..8ef34f012f 100644 --- a/packages/infra/test/contextProvider.test.ts +++ b/packages/infra/test/contextProvider.test.ts @@ -3,8 +3,7 @@ import { expectTypeOf } from "@effect/vitest" import { Context, Effect, Scope } from "effect-app" import { ContextProvider, mergeContextProviders, MergedContextProvider } from "../src/api/routing.js" -import { CustomError1, Some, SomeElse } from "./controller.test.js" -import { SomeService } from "./query.test.js" +import { CustomError1, Some, SomeElse, SomeService } from "./fixtures.js" // @effect-diagnostics-next-line missingEffectServiceDependency:off class MyContextProvider extends Effect.Service()("MyContextProvider", { diff --git a/packages/infra/test/controller.test.ts b/packages/infra/test/controller.test.ts index 9386c43a3c..5cd3b3f0f1 100644 --- a/packages/infra/test/controller.test.ts +++ b/packages/infra/test/controller.test.ts @@ -1,46 +1,24 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { type MakeContext, type MakeErrors, makeRouter } from "@effect-app/infra/api/routing" -import type { RequestContext } from "@effect-app/infra/RequestContext" import { expect, expectTypeOf, it } from "@effect/vitest" -import { type Array, Context, Effect, Layer, Option, S, Scope } from "effect-app" -import { InvalidStateError, makeRpcClient, type RPCContextMap, UnauthorizedError } from "effect-app/client" -import { Class, TaggedError } from "effect-app/Schema" -import { contextMap, DefaultGenericMiddlewares, implementMiddleware, makeMiddleware, makeNewMiddleware, Middleware, Tag } from "../src/api/routing/middleware.js" +import { Context, Effect, Layer, S, Scope } from "effect-app" +import { InvalidStateError, makeRpcClient, NotLoggedInError, UnauthorizedError } from "effect-app/client" +import { DefaultGenericMiddlewares, implementMiddleware, makeMiddleware, makeNewMiddleware, Middleware, Tag } from "../src/api/routing/middleware.js" import { sort } from "../src/api/routing/tsort.js" -import { SomeService } from "./query.test.js" - -export class UserProfile extends Context.assignTag("UserProfile")( - Class("UserProfile")({ - id: S.String, - roles: S.Array(S.String) - }) -) { -} - -export class NotLoggedInError extends TaggedError()("NotLoggedInError", { - message: S.String -}) {} - -export class CustomError1 extends TaggedError()("CustomError1", {}) {} -export class CustomError2 extends TaggedError()("CustomError1", {}) {} - -export interface CTX { - context: RequestContext -} - -export class Some extends Context.TagMakeId("Some", Effect.succeed({ a: 1 }))() {} -export class SomeElse extends Context.TagMakeId("SomeElse", Effect.succeed({ b: 2 }))() {} +import { AllowAnonymous, CustomError1, type RequestContextMap, RequireRoles, Some, SomeElse, SomeService, Test } from "./fixtures.js" // @effect-diagnostics-next-line missingEffectServiceDependency:off class MyContextProvider extends Middleware.Tag()("MyContextProvider", { - provides: [Some] + provides: [Some], + requires: [SomeElse] })({ effect: Effect.gen(function*() { yield* SomeService if (Math.random() > 0.5) return yield* new CustomError1() return Effect.fnUntraced(function*() { + yield* SomeElse // the only requirements you can have are the one provided by HttpRouter.HttpRouter.Provided yield* Scope.Scope @@ -74,71 +52,9 @@ class MyContextProvider2 extends Middleware.Tag()("MyContext // -export type RequestContextMap = { - allowAnonymous: RPCContextMap.Inverted - requireRoles: RPCContextMap.Custom> - test: RPCContextMap -} - const Str = Context.GenericTag<"str", "str">("str") const Str2 = Context.GenericTag<"str2", "str">("str2") -class AllowAnonymous extends Middleware.Tag()("AllowAnonymous", { - dynamic: contextMap()("allowAnonymous") -})({ - effect: Effect.gen(function*() { - return Effect.fnUntraced( - function*({ config, headers }) { - yield* Scope.Scope // provided by HttpRouter.HttpRouter.Provided - const isLoggedIn = !!headers["x-user"] - if (!isLoggedIn) { - if (!config.allowAnonymous) { - return yield* new NotLoggedInError({ message: "Not logged in" }) - } - return Option.none() - } - return Option.some(Context.make( - UserProfile, - { id: "whatever", roles: ["user", "manager"] } - )) - } - ) - }) -}) { -} - -// @effect-diagnostics-next-line missingEffectServiceDependency:off -class RequireRoles extends Middleware.Tag()("RequireRoles", { - dynamic: contextMap()("requireRoles"), - // had to move this in here, because once you put it manually as a readonly static property on the class, - // there's a weird issue where the fluent api stops behaving properly after adding this middleware via `addDynamicMiddleware` - dependsOn: [AllowAnonymous] -})({ - effect: Effect.gen(function*() { - yield* Some - return Effect.fnUntraced( - function*({ config }) { - // we don't know if the service will be provided or not, so we use option.. - const userProfile = yield* Effect.serviceOption(UserProfile) - const { requireRoles } = config - if (requireRoles && !userProfile.value?.roles?.some((role) => requireRoles.includes(role))) { - return yield* new UnauthorizedError({ message: "don't have the right roles" }) - } - return Option.none>() - } - ) - }) -}) { -} - -class Test extends Middleware.Tag()("Test", { dynamic: contextMap()("test") })({ - effect: Effect.gen(function*() { - return Effect.fn(function*() { - return Option.none>() - }) - }) -}) {} - export class BogusMiddleware extends Tag()("BogusMiddleware", { provides: SomeService, wrap: true @@ -204,13 +120,13 @@ const middleware2 = makeMiddleware()({ } }) -export const middleware3 = makeNewMiddleware()( - ...genericMiddlewares -) - .addDynamicMiddleware(AllowAnonymous, RequireRoles) - .addDynamicMiddleware(Test) +export const middleware3 = makeNewMiddleware() + .middleware(...genericMiddlewares) + .middleware(AllowAnonymous, RequireRoles) + .middleware(Test) +// .middleware(BogusMiddleware) -expectTypeOf(middleware3).toEqualTypeOf() +// expectTypeOf(middleware3).toExtend() export type RequestConfig = { /** Disable authentication requirement */ @@ -360,7 +276,7 @@ it("sorts based on requirements", () => { // eslint-disable-next-line unused-imports/no-unused-vars const matched = matchAll({ router }) -expectTypeOf({} as Layer.Context).toEqualTypeOf() +expectTypeOf({} as Layer.Context).toEqualTypeOf() type makeContext = MakeContext expectTypeOf({} as MakeErrors).toEqualTypeOf() @@ -432,7 +348,7 @@ const router2 = r2.Router(Something)({ // eslint-disable-next-line unused-imports/no-unused-vars const matched2 = matchAll({ router: router2 }) -expectTypeOf({} as Layer.Context).toEqualTypeOf() +expectTypeOf({} as Layer.Context).toEqualTypeOf() type makeContext2 = MakeContext expectTypeOf({} as MakeErrors).toEqualTypeOf() diff --git a/packages/infra/test/fixtures.ts b/packages/infra/test/fixtures.ts new file mode 100644 index 0000000000..32ceb78133 --- /dev/null +++ b/packages/infra/test/fixtures.ts @@ -0,0 +1,85 @@ +import { Context, Effect, Option, S, Scope } from "effect-app" +import { NotLoggedInError, type RPCContextMap, UnauthorizedError } from "effect-app/client" +import { TaggedError } from "effect-app/Schema" +import { contextMap, Middleware } from "../src/api/routing.js" + +export class UserProfile extends Context.assignTag("UserProfile")( + S.Class("UserProfile")({ + id: S.String, + roles: S.Array(S.String) + }) +) { +} + +export class Some extends Context.TagMakeId("Some", Effect.succeed({ a: 1 }))() {} +export class SomeElse extends Context.TagMakeId("SomeElse", Effect.succeed({ b: 2 }))() {} + +export type RequestContextMap = { + allowAnonymous: RPCContextMap.Inverted + requireRoles: RPCContextMap.Custom> + test: RPCContextMap +} + +export class AllowAnonymous extends Middleware.Tag()("AllowAnonymous", { + dynamic: contextMap()("allowAnonymous"), + requires: SomeElse +})({ + effect: Effect.gen(function*() { + return Effect.fnUntraced( + function*({ config, headers }) { + yield* SomeElse + yield* Scope.Scope // provided by HttpRouter.HttpRouter.Provided + const isLoggedIn = !!headers["x-user"] + if (!isLoggedIn) { + if (!config.allowAnonymous) { + return yield* new NotLoggedInError({ message: "Not logged in" }) + } + return Option.none() + } + return Option.some(Context.make( + UserProfile, + { id: "whatever", roles: ["user", "manager"] } + )) + } + ) + }) +}) { +} + +// @effect-diagnostics-next-line missingEffectServiceDependency:off +export class RequireRoles extends Middleware.Tag()("RequireRoles", { + dynamic: contextMap()("requireRoles"), + // had to move this in here, because once you put it manually as a readonly static property on the class, + // there's a weird issue where the fluent api stops behaving properly after adding this middleware via `addDynamicMiddleware` + dependsOn: [AllowAnonymous] +})({ + effect: Effect.gen(function*() { + yield* Some + return Effect.fnUntraced( + function*({ config }) { + // we don't know if the service will be provided or not, so we use option.. + const userProfile = yield* Effect.serviceOption(UserProfile) + const { requireRoles } = config + if (requireRoles && !userProfile.value?.roles?.some((role) => requireRoles.includes(role))) { + return yield* new UnauthorizedError({ message: "don't have the right roles" }) + } + return Option.none>() + } + ) + }) +}) { +} + +export class Test extends Middleware.Tag()("Test", { dynamic: contextMap()("test") })({ + effect: Effect.gen(function*() { + return Effect.fn(function*() { + return Option.none>() + }) + }) +}) {} + +export class CustomError1 extends TaggedError()("CustomError1", {}) {} +export class CustomError2 extends TaggedError()("CustomError1", {}) {} + +const MakeSomeService = Effect.succeed({ a: 1 }) +export class SomeService extends Context.TagMakeId("SomeService", MakeSomeService)() {} diff --git a/packages/infra/test/layerUtils.test.ts b/packages/infra/test/layerUtils.test.ts new file mode 100644 index 0000000000..32bb01d7fc --- /dev/null +++ b/packages/infra/test/layerUtils.test.ts @@ -0,0 +1,19 @@ +import { it } from "@effect/vitest" +import { type Layer } from "effect" +import { expectTypeOf } from "vitest/index.cjs" +import { type LayerUtils } from "../src/api/layerUtils.js" + +it("works", () => { + type B = (Layer.Layer | Layer.Layer)[] + type C = LayerUtils.GetLayersContext + type CE = LayerUtils.GetLayersError + + expectTypeOf({} as C).toEqualTypeOf<"a" | "b">() + expectTypeOf({} as CE).toEqualTypeOf<"error-a" | "error-b">() + + type B2 = [Layer.Layer, Layer.Layer | Layer.Layer] + type C2 = LayerUtils.GetLayersContext + type CE2 = LayerUtils.GetLayersError + expectTypeOf({} as C2).toEqualTypeOf<"a" | "b" | "c">() + expectTypeOf({} as CE2).toEqualTypeOf<"error-a" | "error-b" | "error-c">() +}) diff --git a/packages/infra/test/query.test.ts b/packages/infra/test/query.test.ts index 245fbc094d..233839b5d9 100644 --- a/packages/infra/test/query.test.ts +++ b/packages/infra/test/query.test.ts @@ -1,13 +1,14 @@ /* eslint-disable unused-imports/no-unused-vars */ /* eslint-disable @typescript-eslint/no-empty-object-type */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { Context, Effect, flow, Layer, Option, pipe, S, Struct } from "effect-app" +import { Effect, flow, Layer, Option, pipe, S, Struct } from "effect-app" import { inspect } from "util" import { expect, expectTypeOf, it } from "vitest" import { setupRequestContextFromCurrent } from "../src/api/setupRequest.js" import { and, count, make, one, or, order, page, project, type QueryEnd, type QueryProjection, type QueryWhere, toFilter, where } from "../src/Model/query.js" import { makeRepo } from "../src/Model/Repository.js" import { memFilter, MemoryStoreLive } from "../src/Store/Memory.js" +import { SomeService } from "./fixtures.js" const str = S.Struct({ _tag: S.Literal("string"), value: S.String }) const num = S.Struct({ _tag: S.Literal("number"), value: S.Number }) @@ -25,9 +26,6 @@ export declare namespace Something { export interface Encoded extends S.Schema.Encoded {} } -const MakeSomeService = Effect.succeed({ a: 1 }) -export class SomeService extends Context.TagMakeId("SomeService", MakeSomeService)() {} - const q = make() .pipe( // provided automatically inside Repo.q2() where("displayName", "Verona"), diff --git a/packages/infra/test/requires.test.ts b/packages/infra/test/requires.test.ts new file mode 100644 index 0000000000..74b0fcea91 --- /dev/null +++ b/packages/infra/test/requires.test.ts @@ -0,0 +1,75 @@ +import { expectTypeOf, it } from "@effect/vitest" +import { Effect, Layer, S } from "effect-app" +import { makeNewMiddleware, Middleware } from "../src/api/routing.js" +import { AllowAnonymous, type RequestContextMap, RequireRoles, Some, SomeElse, Test } from "./fixtures.js" + +export class SomeMiddleware extends Middleware.Tag()("SomeMiddleware", { + provides: Some, + wrap: true +})({ + effect: Effect.gen(function*() { + // yield* Effect.context<"test-dep">() + return ({ next }) => + Effect.gen(function*() { + // yield* Effect.context<"test-dep2">() + return yield* next.pipe(Effect.provideService(Some, new Some({ a: 1 }))) + }) + }) +}) { +} + +export class SomeElseMiddleware extends Middleware.Tag()("SomeElseMiddleware", { + provides: SomeElse, + wrap: true +})({ + effect: Effect.gen(function*() { + // yield* Effect.context<"test-dep">() + return ({ next }) => + Effect.gen(function*() { + // yield* Effect.context<"test-dep2">() + return yield* next.pipe(Effect.provideService(SomeElse, new SomeElse({ b: 2 }))) + }) + }) +}) { +} + +export class RequiresSomeMiddleware extends Middleware.Tag()("RequiresSomeMiddleware", { + requires: Some, + wrap: true +})({ + effect: Effect.gen(function*() { + // yield* Effect.context<"test-dep">() + return ({ next }) => + Effect.gen(function*() { + yield* Some + // yield* Effect.context<"test-dep2">() + return yield* next + }) + }) +}) { +} + +it("requires gets enforced", async () => { + const middleware3 = makeNewMiddleware() + .middleware(RequiresSomeMiddleware) + .middleware(SomeMiddleware) + .middleware(AllowAnonymous, RequireRoles) + .middleware(SomeElseMiddleware) + .middleware(Test) + + type LayerContext = Layer.Layer.Context + expectTypeOf({} as LayerContext).toEqualTypeOf() + + await Effect + .gen(function*() { + const mw = yield* middleware3 + const mwM = mw.effect(Object.assign({}, S.Any, { config: {} }), (_req) => Effect.void, "some-module") + yield* mwM({}, { "x-user": "test-user" }) + // console.log({ v }) + }) + .pipe( + Effect.scoped, + Effect.provide(middleware3.Default.pipe(Layer.provide(Layer.succeed(Some, new Some({ a: 1 }))))), + Effect.runPromise + ) +})