diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bec45cb..0624681 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -42,8 +42,9 @@ jobs: run: pnpm build - name: Upload coverage reports - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 if: matrix.node-version == '20.x' with: - files: ./coverage/coverage-final.json - fail_ci_if_error: false \ No newline at end of file + files: coverage/lcov.info + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} \ No newline at end of file diff --git a/README.md b/README.md index 279de37..87f3a83 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@

React Router Dataflow

-

A tiny, fully type-safe composer for React Router loaders and actions

+

A tiny, fully type-safe composer for React Router loaders, actions, and route guards.

+

@@ -12,8 +13,7 @@ React Router loaders and actions scale poorly once you start sharing logic. Context becomes implicit, middleware composition is manual, and type safety quickly degrades. -React Router Dataflow fixes this by letting you compose loaders and actions as -typed middleware pipelines, producing a fully type-safe context per route. +React Router Dataflow fixes this by letting you compose loaders, actions, and route guards as typed pipelines, producing a fully type-safe execution context per route. ```typescript import { Loader } from "react-router-dataflow"; @@ -65,7 +65,7 @@ const mw: LoaderMiddleware<{ data: string }> = async (args) => { Loader.with(mw); ``` -> Middlewares can also be typed action-only (`ActionMiddleware`) or universal (`Middleware`). +> Middlewares steps (for loader/action context pipelines) can also be typed action-only (`ActionMiddleware`) or universal (`Middleware`). ### Declare middlewares inline @@ -128,9 +128,44 @@ Action > For more advanced patterns such as context enforcement, parameterized middlewares and middleware factorization, see the [advanced middleware documentation](https://github.com/pheleine/react-router-dataflow/blob/master/docs/advanced_middlewares.md). +## RouteMiddleware - Guarding routes with React Router + +React Router Dataflow includes support for building **React Router middlewares** with a fully typed internal pipeline, using the same `with`/`build` builder API as loaders and actions. +`RouteMiddleware` is a builder that produces a **single React Router middleware** suitable for guarding routes and short-circuiting navigation. + +> `RouteMiddleware` is not related to middlewares types used by `Loader` and `Action` builders which are composing functions of loaders and actions. + +`RouteMiddleware` focuses on composing route guards by letting you compose a sequence of typed steps called **route requirements**. +Each requirement runs before the route is entered, can enrich internal context, and can throw a `Response` to block navigation. +Route requirements can be predefined in the same way as [loaders and actions middlewares](#define-reusable-middlewares) using `RouteRequirement` type. + +> `RouteMiddleware` does not support post-processing or response interception. +> If you need a pre/post middleware, write a React Router middleware directly and compose it alongside `RouteMiddleware`. + +> *Terminology* +> - **Middleware**: a loader/action pipeline step +> - **RouteMiddleware**: a builder that produces a React Router middleware +> - **RouteRequirement**: a typed guard step executed inside a RouteMiddleware + +```typescript +import { RouteMiddleware } from "react-router-dataflow"; +import { requireAuth } from "~/guards/require-auth"; + +// Ensures user is authenticated and has "admin" role +RouteMiddleware + .with(requireAuth) // => { user } + .build(async (_, { user }) => { + if (user.role !== "admin") { + throw new Response("Unauthorized", { status: 401 }); + } + + return null; + }); +``` + ## Integration in React Router -Loaders and actions created with React Router Dataflow are standard React Router data functions and behave the same way on the client and on the server. +Loaders, actions, and route middlewares created with React Router Dataflow are standard React Router primitives and behave the same way on the client and on the server. They can be used in: - framework mode (route files) - data routers (`createBrowserRouter`) @@ -139,21 +174,33 @@ They can be used in: ### Framework mode example ```tsx -// app/routes/example.tsx +// app/routes/data.$id.tsx import { Loader, Action } from "react-router-dataflow"; -import { requireAuth } from "~/middlewares/require-auth"; - -// Auth is enforced, no data is returned -export const loader = Loader.with(requireAuth).build(); - -// Auth is enforced, only PATCH/patch requests are handled -export const action = Action.with(requireAuth).build({ - PATCH: async ({ request }) => { - const updatedData = /* data update using request */; - return { updatedData }; - } -}); +import { requireAuth } from "~/guards/require-auth"; +import { dataFromParams } from "~/middlewares/data-from-params"; + +// Auth is enforced on the route +export const middleware = [ + RouteMiddleware + .with(requireAuth) + .build() +]; + +// => reached only if authenticated, get data from route params +export const loader = Loader + .with(dataFromParams) // => { data } + .build(async (_, { data }) => data); + +// => reached only if authenticated, only PATCH/patch requests are handled +export const action = Action + .with(dataFromParams) // => { data } + .build({ + PATCH: async ({ request }, { data }) => { + const updatedData = /* data update using request */; + return updatedData; + } + }); const ExamplePage = () =>

Example page

; diff --git a/package.json b/package.json index aa72867..e593ed1 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "react-router-dataflow", - "version": "1.0.1", + "version": "1.1.0", "license": "MIT", "type": "module", "author": "Paulin Héleine", - "description": "A tiny, fully type-safe composer for React Router loaders and actions", + "description": "A tiny, fully type-safe composer for React Router loaders, actions, and route guards", "sideEffects": false, "files": [ "dist", diff --git a/src/data-functions.ts b/src/data-functions.ts new file mode 100644 index 0000000..1534b13 --- /dev/null +++ b/src/data-functions.ts @@ -0,0 +1,102 @@ +import { LoaderFunctionArgs, ActionFunctionArgs } from "react-router"; +import { Normalize, ContextBuilder } from "./shared-types"; + +type DataFunctionArgs = LoaderFunctionArgs | ActionFunctionArgs; +type DataFunction = (args: A) => Promise>; + +type Handler = (args: A, context: C) => Promise>; + +type InternalMiddleware = (args: A, context: C) => Promise; +type LoaderMiddleware = InternalMiddleware; +type ActionMiddleware = InternalMiddleware; +type Middleware = InternalMiddleware; + +type LoaderBuilder = { + with(middleware: LoaderMiddleware): LoaderBuilder>; + build(handler: Handler): DataFunction; + build(): DataFunction; +}; + +const createLoaderBuilder = (buildContext: ContextBuilder): LoaderBuilder => ({ + with(middleware: LoaderMiddleware) { + return createLoaderBuilder(async (args) => { + const context = await buildContext(args); + const result = await middleware(args, context); + + return (result === null ? context : { ...context, ...result }) as C & Normalize; + }); + }, + + // handlers must be typed to allow its optionality. The function overloads + // ensure type safety at the call site, but any must be used to type handler. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + build(handler?: Handler) { + return async (args: LoaderFunctionArgs) => { + const context = await buildContext(args); + + return handler !== undefined ? handler(args, context) : null; + }; + } +}); + +const Loader = createLoaderBuilder(async () => ({})); + +type ActionBuilder = { + with(middleware: ActionMiddleware): ActionBuilder>; + + // Use of any is necessary to allow different return types for handlers. + // The return type of the resulting function is infered automatically from their return types. + // TypeScript cannot express a constraint that allows "any R" while still preserving + // the ability to infer the union of all handler return types. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + build>>(handlers: H): DataFunction>>; +}; + +const createActionBuilder = (buildContext: ContextBuilder): ActionBuilder => ({ + with(middleware: ActionMiddleware) { + return createActionBuilder(async (args) => { + const context = await buildContext(args); + const result = await middleware(args, context); + + return (result === null ? context : { ...context, ...result }) as C & Normalize; + }); + }, + + build(handlers) { + // The return type cannot be known in advance as handlers can have different return types. + // Any is necessary because TypeScript cannot express a Record type where each value + // has a different generic type parameter (R) while maintaining type safety. + // The actual return type is correctly inferred at the call site through the overload signature. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handlersMap: Record> = {}; + + for (const [ key, handler ] of Object.entries(handlers)) { + handlersMap[key.toLowerCase()] = handler; + } + + return async (args) => { + const context = await buildContext(args); + const method = args.request.method.toLowerCase(); + const handler = handlersMap[method]; + + if (!handler) { + throw new Response("Method not allowed", { status: 405 }); + } + + return handler(args, context); + }; + } +}); + +const Action = createActionBuilder(async () => ({})); + +export { + Loader, + Action +}; + +export type { + Middleware, + LoaderMiddleware, + ActionMiddleware +}; diff --git a/src/index.ts b/src/index.ts index 4fb0b18..9e9529a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,110 +1,15 @@ -import type { LoaderFunctionArgs, ActionFunctionArgs } from "react-router"; - -type DataFunctionArgs = LoaderFunctionArgs | ActionFunctionArgs; -type DataFunction = (args: A) => Promise>; - -type Handler = (args: A, context: C) => Promise>; - -type InternalMiddleware = (args: A, context: C) => Promise; -type LoaderMiddleware = InternalMiddleware; -type ActionMiddleware = InternalMiddleware; -type Middleware = InternalMiddleware; - -type ContextBuilder = (args: A) => Promise; - -type Normalize = R extends null ? object : R; - -type LoaderBuilder = { - with(middleware: LoaderMiddleware): LoaderBuilder>; - build(handler: Handler): DataFunction; - build(): DataFunction; -}; - -const createLoaderBuilder = (buildContext: ContextBuilder): LoaderBuilder => ({ - with(middleware: LoaderMiddleware) { - return createLoaderBuilder(async (args) => { - const context = await buildContext(args); - const result = await middleware(args, context); - - return (result === null ? context : { ...context, ...result }) as C & Normalize; - }); - }, - - // handlers must be typed to allow its optionality. The function overloads - // ensure type safety at the call site, but any must be used to type handler. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - build(handler?: Handler) { - if (handler === undefined) { - return async () => null; - } - - return async (args: LoaderFunctionArgs) => { - const context = await buildContext(args); - - return handler(args, context); - }; - } -}); - -const Loader = createLoaderBuilder(async () => ({})); - -type ActionBuilder = { - with(middleware: ActionMiddleware): ActionBuilder>; - - // Use of any is necessary to allow different return types for handlers. - // The return type of the resulting function is infered automatically from their return types. - // TypeScript cannot express a constraint that allows "any R" while still preserving - // the ability to infer the union of all handler return types. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - build>>(handlers: H): DataFunction>>; -}; - -const createActionBuilder = (buildContext: ContextBuilder): ActionBuilder => ({ - with(middleware: ActionMiddleware) { - return createActionBuilder(async (args) => { - const context = await buildContext(args); - const result = await middleware(args, context); - - return (result === null ? context : { ...context, ...result }) as C & Normalize; - }); - }, - - build(handlers) { - // The return type cannot be known in advance as handlers can have different return types. - // Any is necessary because TypeScript cannot express a Record type where each value - // has a different generic type parameter (R) while maintaining type safety. - // The actual return type is correctly inferred at the call site through the overload signature. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const handlersMap: Record> = {}; - - for (const [ key, handler ] of Object.entries(handlers)) { - handlersMap[key.toLowerCase()] = handler; - } - - return async (args) => { - const method = args.request.method.toLowerCase(); - const handler = handlersMap[method]; - - if (!handler) { - throw new Response("Method not allowed", { status: 405 }); - } - - const context = await buildContext(args); - - return handler(args, context); - }; - } -}); - -const Action = createActionBuilder(async () => ({})); +import { Loader, Action, LoaderMiddleware, ActionMiddleware, Middleware } from "./data-functions"; +import { RouteMiddleware, RouteRequirement } from "./route-middleware"; export { Loader, - Action + Action, + RouteMiddleware }; export type { - Middleware, LoaderMiddleware, - ActionMiddleware + ActionMiddleware, + Middleware, + RouteRequirement }; diff --git a/src/route-middleware.ts b/src/route-middleware.ts new file mode 100644 index 0000000..5a22243 --- /dev/null +++ b/src/route-middleware.ts @@ -0,0 +1,47 @@ +import type { MiddlewareFunction, Params } from "react-router"; +import { Normalize, ContextBuilder } from "./shared-types"; + +type RouteMiddlewareArgs = { + request: Request; + params: Params; +}; + +type RouteRequirement = (args: RouteMiddlewareArgs, context: C) => Promise; +type RouteMiddlewareHandler = (args: RouteMiddlewareArgs, context: C) => Promise; + +type RouteMiddlewareBuilder = { + with(requirement: RouteRequirement): RouteMiddlewareBuilder>; + build(handler: RouteMiddlewareHandler): MiddlewareFunction; + build(): MiddlewareFunction; +}; + +const createRouteMiddlewareBuilder = (buildContext: ContextBuilder): RouteMiddlewareBuilder => ({ + with(requirement: RouteRequirement) { + return createRouteMiddlewareBuilder(async (args) => { + const context = await buildContext(args); + const result = await requirement(args, context); + + return (result === null ? context : { ...context, ...result }) as C & Normalize; + }); + }, + build(handler?: RouteMiddlewareHandler) { + return async (args, next) => { + const context = await buildContext(args); + + if (handler === undefined) { + return next(); + } + + return (await handler(args, context)) ?? next(); + }; + } +}); + +const RouteMiddleware = createRouteMiddlewareBuilder(async () => ({})); + +export { RouteMiddleware }; + +export type { + RouteRequirement, + RouteMiddlewareArgs +}; diff --git a/src/shared-types.ts b/src/shared-types.ts new file mode 100644 index 0000000..d03e526 --- /dev/null +++ b/src/shared-types.ts @@ -0,0 +1,7 @@ +type ContextBuilder = (args: A) => Promise; +type Normalize = R extends null ? object : R; + +export type { + ContextBuilder, + Normalize +}; diff --git a/tests/action.test.ts b/tests/action.test.ts index e34bec2..5a90cf1 100644 --- a/tests/action.test.ts +++ b/tests/action.test.ts @@ -240,3 +240,45 @@ describe("Action builder intermediate steps", () => { expect(result).toBe("post"); }); }); + +describe("Middlewares on the action usage", () => { + it("should be executed when method is handled", async () => { + let called = false; + + const action = Action + .with(async () => { + called = true; + + return null; + }) + .build({ + POST: async () => "post" + }); + + await action(MOCK_ARGS_POST); + + expect(called).toBe(true); + }); + + it("should be executed when method is not handled", async () => { + let called = false; + + const action = Action + .with(async () => { + called = true; + + return null; + }) + .build({ + POST: async () => "post" + }); + + try { + await action(MOCK_ARGS_DELETE); + } catch (error) { + expect(error).toBeInstanceOf(Response); + } + + expect(called).toBe(true); + }); +}); diff --git a/tests/loader.test.ts b/tests/loader.test.ts index 3c0b871..cd23ab3 100644 --- a/tests/loader.test.ts +++ b/tests/loader.test.ts @@ -171,3 +171,37 @@ describe("Loader builder intermediate steps", () => { expect(result).toBeNull(); }); }); + +describe("Middlewares on the loader usage", () => { + it("should be executed with default build", async () => { + let called = false; + + const loader = Loader + .with(async () => { + called = true; + + return null; + }) + .build(); + + await loader(MOCK_ARGS); + + expect(called).toBe(true); + }); + + it("should be executed with custom build", async () => { + let called = false; + + const loader = Loader + .with(async () => { + called = true; + + return null; + }) + .build(async () => null); + + await loader(MOCK_ARGS); + + expect(called).toBe(true); + }); +}); diff --git a/tests/route-middleware.test.ts b/tests/route-middleware.test.ts new file mode 100644 index 0000000..f2c8734 --- /dev/null +++ b/tests/route-middleware.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import { RouteMiddleware, RouteRequirement } from "../src"; +import { RouteMiddlewareArgs } from "../src/route-middleware"; + +const MOCK_ARGS = { + params: { id: "test" }, + request: new Request("http://example.com", { method: "GET" }), + // Any is used to mock the RouterContextProvider of React Router for testing. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + context: {} as any, + unstable_pattern: "" +}; +const MOCK_NEXT = async () => new Response(); +const CUSTOM_RESPONSE = new Response(null, { statusText: "custom response" }); + +describe("Route Middleware Builder", () => { + it("should build with an empty handler", async () => { + const routeMiddleware = RouteMiddleware.build(); + const result = await routeMiddleware(MOCK_ARGS, MOCK_NEXT); + + expect(result).toStrictEqual(await MOCK_NEXT()); + }); + + it("should build with a handler that returns nothing", async () => { + const routeMiddleware = RouteMiddleware.build(async () => { + return null; + }); + const result = await routeMiddleware(MOCK_ARGS, MOCK_NEXT); + + expect(result).toStrictEqual(await MOCK_NEXT()); + }); + + it("should build with a handler that returns a Response", async () => { + const routeMiddleware = RouteMiddleware.build(async () => { + return CUSTOM_RESPONSE; + }); + const result = await routeMiddleware(MOCK_ARGS, MOCK_NEXT); + + expect(result).toStrictEqual(CUSTOM_RESPONSE); + }); + + it("should pass function args to the handler", async () => { + const routeMiddleware = RouteMiddleware.build(async (args) => { + expectTypeOf(args).toEqualTypeOf(); + expect(args).toMatchObject({ + request: MOCK_ARGS.request, + params: MOCK_ARGS.params + }); + + return null; + }); + + await routeMiddleware(MOCK_ARGS, MOCK_NEXT); + }); +}); + +describe("Route Middleware builder intermediate steps", () => { + it("should enrich context with inline requirements", async () => { + const routeMiddleware = RouteMiddleware + .with(async () => { + return { + first: true + }; + }) + .with(async (_, context) => { + expectTypeOf(context).toMatchObjectType<{ first: boolean }>(); + expect(context).toStrictEqual({ first: true }); + + return { + second: "second" + }; + }) + .build(async (_, context) => { + expectTypeOf(context).toMatchObjectType<{ + first: boolean; + second: string; + }>(); + expect(context).toStrictEqual({ + first: true, + second: "second" + }); + + return null; + }); + + await routeMiddleware(MOCK_ARGS, MOCK_NEXT); + }); + + it("should enrich context with predefined requirements", async () => { + const requireOne: RouteRequirement<{ requireOne: string }> = async () => { + return { requireOne: "requireOne" }; + }; + + const requireTwo: RouteRequirement<{ requireTwo: string }> = async () => { + return { requireTwo: "requireTwo" }; + }; + + const routeMiddleware = RouteMiddleware + .with(requireOne) + .with(requireTwo) + .build(async (_, context) => { + expectTypeOf(context).toMatchObjectType<{ + requireOne: string; + requireTwo: string; + }>(); + expect(context).toStrictEqual({ + requireOne: "requireOne", + requireTwo: "requireTwo" + }); + + return null; + }); + + await routeMiddleware(MOCK_ARGS, MOCK_NEXT); + }); + + it("should not enrich context if requirements return null", async () => { + const routeMiddleware = RouteMiddleware + .with(async () => ({ first: true })) + .with(async () => ({ second: true })) + .with(async () => null) + .build(async (_, context) => { + expectTypeOf(context).toMatchObjectType<{ + first: boolean; + second: boolean; + }>(); + expect(context).toStrictEqual({ first: true, second: true }); + + return null; + }); + + await routeMiddleware(MOCK_ARGS, MOCK_NEXT); + }); + + it("should receive the args given to the route middleware", async () => { + const routeMiddleware = RouteMiddleware + .with(async (args) => { + expectTypeOf(args).toEqualTypeOf(); + expect(args).toMatchObject({ + request: MOCK_ARGS.request, + params: MOCK_ARGS.params + }); + + return null; + }) + .build(); + + await routeMiddleware(MOCK_ARGS, MOCK_NEXT); + }); +}); + +describe("Requirements on the route middleware usage", () => { + it("should be executed with default build", async () => { + let called = false; + + const routeMiddleware = RouteMiddleware + .with(async () => { + called = true; + + return null; + }) + .build(); + + await routeMiddleware(MOCK_ARGS, MOCK_NEXT); + + expect(called).toBe(true); + }); + + it("should be executed with custom build", async () => { + let called = false; + + const routeMiddleware = RouteMiddleware + .with(async () => { + called = true; + + return null; + }) + .build(async () => null); + + await routeMiddleware(MOCK_ARGS, MOCK_NEXT); + + expect(called).toBe(true); + }); +});