diff --git a/design/DESIGN_typed_context_pipeline.md b/design/DESIGN_typed_context_pipeline.md new file mode 100644 index 00000000..60df874f --- /dev/null +++ b/design/DESIGN_typed_context_pipeline.md @@ -0,0 +1,451 @@ +# Design: Typed Context Pipeline for Relic + +## 1. Introduction and Goals + +This document proposes a design for a typed context pipeline in Relic. The primary goal is to achieve **compile-time safety** for middleware and handler composition. This means the Dart analyzer should be able to verify that if a handler (or subsequent middleware) expects certain data to be present in its request context (e.g., an authenticated `User` object), the necessary preceding middleware (e.g., an `AuthenticationMiddleware`) has been correctly configured in the pipeline. + +This approach aims to: +* Catch pipeline configuration errors at compile-time rather than runtime. +* Improve developer ergonomics by providing clear, type-safe access to context data. +* Maintain high performance by leveraging zero-cost abstractions like extension types. +* Avoid a "class explosion" that might occur if every combination of context data required a distinct context class. + +## 2. Core Components + +The system relies on a few key components: + +### 2.1. Relic's `RequestContext` and Stable Request Token + +This design utilizes Relic's existing `RequestContext` (defined in `relic/lib/src/adapter/context.dart`). The key aspects of `RequestContext` relevant to this design are: +* It provides access to the current `Request` object (e.g., via a `request` getter). +* It contains a stable `token` (e.g., via a `token` getter). This `token` is a unique identifier for the entire lifecycle of a single request and remains constant even if the `RequestContext` instance itself undergoes state transitions (e.g., from `NewContext` to `ResponseContext`). + +All request-scoped data attached via `Expando` (managed by `ContextProperty`) will be keyed off this stable `requestToken`. The extension type views defined in this design will wrap an instance of Relic's `RequestContext`. + +```dart +// Note: This design uses Relic's existing RequestContext. +// For illustration, its relevant properties would be: +class RequestContext { + final Request request; + final Object token; // The stable, unique-per-request token + // ... +} +// Data is attached via Expandos keyed by 'token', managed by +// ContextProperty and accessed through context views. +``` + +### 2.2. Data Classes + +These are simple Dart classes representing the data that middleware can add to the context. + +```dart +class User { + final String id; + final String email; + User({required this.id, required this.email}); +} + +class Session { + final String sessionId; + DateTime expiresAt; + Session({required this.sessionId, required this.expiresAt}); +} +``` + +### 2.3. `ContextProperty` Helper + +To simplify and standardize the management of `Expando`-based context data, a helper class `ContextProperty` is introduced. This class encapsulates an `Expando` and ensures that data is consistently keyed off the stable `requestToken`. + +```dart +class ContextProperty { + final Expando _expando; // Use token from RequestContext as anchor + final String? _debugName; // Optional: for Expando's name + + ContextProperty([this._debugName]) : _expando = Expando(_debugName); + + T get(RequestContext requestContext) { + final value = _expando[requestContext.token]; + if (value == null) { + throw StateError( + 'ContextProperty value not found. Property: ${_debugName ?? T.toString()}. ' + 'Ensure middleware has set this value for the request token.'); + } + return value; + } + + T? getOrNull(RequestContext requestContext) { + return _expando[requestContext.token]; + } + + void set(RequestContext requestContext, T value) { + _expando[requestContext.token] = value; + } + + bool exists(RequestContext requestContext) { + return _expando[requestContext.token] != null; + } + + void clear(RequestContext requestContext) { + _expando[requestContext.token] = null; // Clears the association in Expando + } +} +``` +Modules responsible for specific pieces of context data (e.g., `User`, `Session`) will define a private static `ContextProperty` instance. + +### 2.4. Extension Types for Context Views + +Extension types are zero-cost abstractions (wrappers) over an instance of Relic's `RequestContext`. They provide a type-safe "view" or "contract" for accessing and attaching specific data, using `ContextProperty` instances internally. + +Each view typically provides: +* Getters for the data it represents (e.g., `UserContextView.user`). +* Methods to attach or set its data (e.g., `UserContextView.attachUser(User user)`). These methods use the appropriate `ContextProperty` and the `requestToken`. + +```dart +// Define a ContextProperty for User data. +// This would typically be a static final field, private to its library, +// in a relevant class or top-level. +final _userProperty = ContextProperty('relic.auth.user'); + +// Base view that all request contexts can be seen as initially. +// It wraps Relic's RequestContext. +extension type BaseContextView(RequestContext _relicContext) { + Request get request => _relicContext.request; +} + +// A view indicating that User information is available. +extension type UserContextView(RequestContext _relicContext) implements BaseContextView { + User get user => _userProperty.get(_relicContext); + + void attachUser(User user) { + _userProperty.set(_relicContext, user); + } + + // Optional: to check for user presence or get a nullable user + User? get userOrNull => _userProperty.getOrNull(_relicContext); + bool get hasUser => _userProperty.exists(_relicContext); +} + +// A view indicating that Session information is available. +extension type SessionContextView(RequestContext _relicContext) implements BaseContextView { + Session get session => _sessionProperty.get(_relicContext); + + void attachSession(Session session) { + _sessionProperty.set(_relicContext, session); + } +} +final _sessionProperty = ContextProperty('relic.session'); + +// A composite view indicating both User and Session information are available. +extension type UserSessionContextView(RequestContext _relicContext) implements UserContextView, SessionContextView { + // Getters for 'user' and 'session' are inherited via UserContextView and SessionContextView. + // The 'attachUser' and 'attachSession' methods are also available if needed, + // though typically data is attached by the specific middleware responsible for it. +} +``` +Middleware will use these view-specific `attach` methods (e.g., `userView.attachUser(newUser)`), which internally leverage `ContextProperty` to manage data associated with the `requestToken`. + +## 3. Middleware Definition + +Middleware are defined as functions (or methods on stateless service objects) that: +1. Take an input context view (e.g., `BaseContextView`, `UserContextView`). +2. Perform their logic, attaching data to the stable `requestToken` (obtained via `inputView.requestToken` or `inputView._relicContext.token`). This is done using the view's `attach` methods (e.g., `inputView.attachUser(user)`), which internally use `ContextProperty`. +3. Return an output context view (e.g., `UserContextView`, `UserSessionContextView`) that wraps the *same* `RequestContext` instance. The type of the returned view signals the new capabilities/data available via the `requestToken`. + +```dart +// This middleware takes a BaseContextView, authenticates the request, +// attaches a User object (via ContextProperty and the requestToken), and returns a UserContextView. +UserContextView authenticationMiddleware(BaseContextView inputView) { + // Simplified authentication logic + final token = inputView.request.headers['Authorization']; + // Create the UserContextView to use its `attachUser` method. + // The underlying RequestContext (and thus its token) is passed along. + final userView = UserContextView(inputView._relicContext); + + if (token == 'Bearer valid-token') { + final user = User(id: 'user-123', email: 'user@example.com'); + userView.attachUser(user); // Use the view's method to attach the user + } else { + // Handle failed authentication: + // Option A: Throw an error that the server translates to a 401/403 response. + // throw AuthenticationError('Invalid or missing token'); + // Option B: Do not set the user. The UserContextView.user getter would then fail, + // or UserContextView would need to expose 'User? get user' or 'bool get hasUser'. + // For this design, we assume successful authentication is required if returning UserContextView. + // If authentication is optional, the middleware might return a different view type or + // UserContextView.user might be nullable. + // Forcing a User to be present if returning UserContextView makes the contract stronger. + } + + // Return a UserContextView, wrapping the same (now modified) CoreRequestContext + return userView; +} +``` + +## 4. Type-Safe `PipelineBuilder` + +The `PipelineBuilder` is a generic class responsible for composing middleware and a final handler in a type-safe manner. It uses Dart's generic type system to track the "shape" (capabilities) of the context view as it evolves through the pipeline. + +```dart +class PipelineBuilder { + /// The function representing the composed chain of middleware so far. + /// It transforms an input view (TCurrentChainInputView) to an output view (TCurrentChainOutputView). + final TCurrentChainOutputView Function(TCurrentChainInputView) _chain; + + PipelineBuilder._(this._chain); + + /// Starts a new pipeline. + /// The initial view for the chain is `BaseContextView`. + static PipelineBuilder start() { + // The initial chain is an identity function: it receives a BaseContextView and returns it. + return PipelineBuilder._((BaseContextView view) => view); + } + + /// Adds a middleware to the current pipeline. + /// - `middleware`: A function that takes the output view of the current chain (`TCurrentChainOutputView`) + /// and produces a new view (`TNextChainOutputView`). + /// Returns a new PipelineBuilder instance representing the extended chain. + PipelineBuilder add( + TNextChainOutputView Function(TCurrentChainOutputView currentView) middleware, + ) { + // Compose the existing chain with the new middleware: + // The new chain takes the original input (TCurrentChainInputView), + // applies the old chain to get TCurrentChainOutputView, + // then applies the new middleware to get TNextChainOutputView. + return PipelineBuilder._( + (TCurrentChainInputView initialView) { + final previousOutput = _chain(initialView); + return middleware(previousOutput); + }); + } + + /// Finalizes the pipeline with a handler. + /// - `handler`: A function that takes the final output view of the middleware chain (`TCurrentChainOutputView`) + /// and produces a `FutureOr`. + /// Returns a single function that takes a `Request`, sets up the context, executes the pipeline, + /// and returns the `FutureOr`. + FutureOr Function(NewContext request) build( + FutureOr Function(TCurrentChainOutputView finalView) handler, + ) { + // The fully composed chain from the initial TCurrentChainInputView (which should be BaseContextView for a `start()`ed pipeline) + // to the final TCurrentChainOutputView. + final TCurrentChainOutputView Function(TCurrentChainInputView) + completeMiddlewareChain = _chain; + + return (NewContext ctx) { + // This cast assumes the pipeline was started with `PipelineBuilder.start()`, + // making TCurrentChainInputView effectively BaseContextView. + final initialView = + BaseContextView(ctx) as TCurrentChainInputView; + + // Execute the middleware chain. + final finalView = completeMiddlewareChain(initialView); + + // Execute the final handler with the processed context view. + return handler(finalView); + }; + } +} +``` + +## 5. Usage Example + +This demonstrates how to build a pipeline and how type errors would be caught. + +```dart +// Assuming SessionMiddleware is: +// UserSessionContextView sessionMiddleware(UserContextView userView) { ... } + +// Define a handler that expects both User and Session data. +Future mySecureHandler(UserSessionContextView context) async { + final user = context.user; + final session = context.session; + return Response.ok('User: ${user.email}, Session ID: ${session.sessionId}'); +} + +void setupServer() { + final requestHandler = PipelineBuilder.start() // Starts with BaseContextView + .add(authenticationMiddleware) // Output view: UserContextView + .add(sessionMiddleware) // Input: UserContextView, Output: UserSessionContextView + .build(mySecureHandler); // Handler expects UserSessionContextView - OK! + + // This handler can now be passed to Relic's server serving mechanism. + // relicServe(requestHandler, ...); + + // Example of a compile-time error: + // final faultyHandler = PipelineBuilder.start() + // .add(authenticationMiddleware) // Output: UserContextView + // // Missing sessionMiddleware + // .build(mySecureHandler); // COMPILE ERROR: mySecureHandler expects UserSessionContextView, + // // but pipeline only guarantees UserContextView. +} +``` + +## 6. Benefits + +* **Compile-Time Safety**: The primary goal. Misconfigured pipelines (e.g., missing middleware, incorrect order affecting context data) are caught by the Dart analyzer. +* **Improved Developer Ergonomics**: + * Handlers and middleware can declare precisely the context view (and thus data) they expect. + * Access to context data via extension type getters is type-safe and clear (e.g., `context.user`). +* **Minimal Runtime Overhead for Views**: Extension types are intended to be zero-cost compile-time wrappers. The `ContextProperty` helper encapsulates `Expando` lookups/attachments, which are generally efficient. +* **No Class Explosion**: Avoids needing a distinct context `class` for every possible combination of middleware. Extension types provide views, and `ContextProperty` manages data association with the stable `requestToken`. +* **Clarity and Documentation**: The type signatures of middleware and handlers explicitly document their context dependencies. View methods (e.g., `attachUser`) and `ContextProperty` provide clear, discoverable APIs for data management. +* **Modularity & Encapsulation**: `ContextProperty` encapsulates `Expando` usage. Modules define their data properties cleanly. + +## 7. Middleware Paradigm Shift and Implications + +This typed pipeline introduces a shift from the traditional `Middleware = Handler Function(Handler innerHandler)` pattern previously used. Understanding these changes is crucial: + +* **New Middleware Signature**: Middleware in this design are functions with a signature like `OutputView Function(InputView)`. They transform context views rather than wrapping an inner handler. +* **Linear Chain**: The `PipelineBuilder` composes middleware into a linear chain of context transformations. Each middleware is expected to process the context and pass control (via its return value) to the next stage defined by the builder. +* **Short-Circuiting (e.g., Denying Access)**: + * Middleware should not directly return a `Response` to short-circuit the pipeline. + * Instead, if a middleware needs to stop processing and return an error (e.g., an authentication middleware denying access due to an invalid token), it should **throw a specific exception** (e.g., `AuthorizationRequiredError("Invalid token")`, `PaymentRequiredError()`). + * The main server error handling logic (external to this pipeline execution) would then catch these specific exceptions and convert them into appropriate HTTP `Response` objects (e.g., status codes 401, 403, 402). + * This keeps middleware focused on context validation/transformation, with exceptions managing early exits. +* **Complex Conditional Logic or "Nested" Operations**: + * The `InputView -> OutputView` signature doesn't inherently support conditional invocation of different sub-handlers or complex branching within the middleware itself in the same way the `Handler Function(Handler)` pattern does. + * Such logic is often best placed within the **final handler** (the function passed to `PipelineBuilder.build(...)`). This handler receives the fully prepared, type-safe context. Inside this handler, developers can use standard Dart control flow (`if/else`, `switch`) or call other services/functions which might internally manage their own complex operations (potentially even using other `PipelineBuilder` instances for sub-tasks if appropriate, though this is advanced). + * Alternatively, a middleware could add data to the context that signals a specific route or action, which subsequent middleware or the final handler then interprets. +* **Trade-offs**: + * **Gained**: Strong compile-time type safety for the data flowing through the request context. This significantly reduces a class of runtime errors due to misconfigured pipelines. + * **Different Flexibility**: Some dynamic flexibility found in the `Handler Function(Handler)` pattern (e.g., complex around-logic, dynamically choosing the next handler in the chain from within a middleware) is handled differently (e.g., via exceptions, or by moving logic into handlers). For many common middleware tasks (logging, data enrichment, simple auth checks), the typed pipeline offers a clearer and safer model. + +## 8. Integrating with Routing + +The typed context pipeline is designed to prepare a rich, type-safe context that can then be utilized by a routing system to dispatch requests to appropriate endpoint handlers. Relic's `Router` class can be seamlessly integrated with this pipeline. + +The core idea is that the `PipelineBuilder` sets up a chain of common middleware (e.g., authentication, session management, logging). The final function passed to `PipelineBuilder.build(...)` will be a "routing dispatcher." This dispatcher uses the context prepared by the common middleware, performs route matching, potentially adds route-specific data (like path parameters) to the context, and then executes the endpoint handler chosen by the router. + +### 8.1. Context for Route Parameters + +Endpoint handlers often need access to path parameters extracted during routing (e.g., the `:id` in `/users/:id`). A `ContextProperty` and corresponding view should be defined for these. + +```dart +// Example: Data class for route parameters +class RouteParameters { + final Map params; + RouteParameters(this.params); + + String? operator [](Symbol key) => params[key]; + // Potentially add other useful accessors +} + +// Example: Private ContextProperty for route parameters +// (Typically defined in a routing-related module/library) +final _routeParametersProperty = + ContextProperty('relic.routing.parameters'); +``` + +### 8.2. Context Views for Endpoint Handlers + +Endpoint handlers will require a context view that provides access to both the common context data (prepared by the initial pipeline) and the specific `RouteParameters`. + +```dart +// Example: A view that combines UserContext (from common pipeline) and RouteParameters +// (Assumes UserContextView is already defined) +extension type UserRouteContextView(RequestContext _relicContext) implements UserContextView { + RouteParameters get routeParams => _routeParametersProperty.get(_relicContext); + + // This method will be called by the routing dispatcher after parameters are extracted. + void attachRouteParameters(RouteParameters params) { + _routeParametersProperty.set(_relicContext, params); + } +} + +// Other combinations can be created as needed (e.g., BaseRouteContextView, UserSessionRouteContextView). +``` + +### 8.3. Router's Generic Type `T` + +The generic type `T` in `Router` will represent the actual endpoint handler functions. These functions will expect an enriched context view that includes common context data and route parameters. + +For example, `T` could be: +`FutureOr Function(UserRouteContextView context)` + +### 8.4. The Routing Dispatcher Function + +This function is passed to `PipelineBuilder.build(...)`. It receives the context prepared by the common middleware chain (e.g., `UserContextView`). Its responsibilities are: +1. Use the incoming request details (from the context) to perform a route lookup via `Router`. +2. Handle cases where no route is matched (e.g., by throwing a `RouteNotFoundException` or returning a 404 `Response`). +3. If a route is matched, extract the endpoint handler and any path parameters. +4. Create the specific context view required by the endpoint handler (e.g., `UserRouteContextView`), attaching the extracted `RouteParameters` to it. +5. Execute the chosen endpoint handler with this enriched context. + +```dart +// Example: Routing Dispatcher +// Assume 'myAppRouter' is an instance of Router Function(UserRouteContextView)> +// Assume 'UserContextView' is the output view from the common middleware pipeline. + +FutureOr routingDispatcher(UserContextView commonContext) { + final request = commonContext.request; + + // Perform route lookup using Relic's Router. + final lookupResult = myAppRouter.lookup( + request.method.convert(), // Or however method is represented + request.uri.path); + + if (lookupResult == null || lookupResult.value == null) { + // Option 1: Throw a specific RouteNotFoundException for centralized error handling. + throw RouteNotFoundException( + 'Route not found for ${request.method.value} ${request.uri.path}'); + // Option 2: Directly return a 404 Response (less flexible for global error handling). + // return Response.notFound(...); + } + + final endpointHandler = lookupResult.value; + final pathParams = RouteParameters(lookupResult.parameters); + + // Create the specific context view for the endpoint handler by wrapping the same + // underlying RequestContext and attaching the extracted route parameters. + final endpointContext = UserRouteContextView(commonContext._relicContext); + endpointContext.attachRouteParameters(pathParams); + + // Execute the chosen endpoint handler. + return endpointHandler(endpointContext); +} +``` + +### 8.5. Pipeline Setup with Routing + +The `PipelineBuilder` is used to construct the common middleware chain, with the `routingDispatcher` as the final step. + +```dart +// Example: In your server setup +void setupServer(Router Function(UserRouteContextView)> myAppRouter) { // Pass your router + final requestHandler = PipelineBuilder.start() // Input: BaseContextView + .add(authenticationMiddleware) // Output: UserContextView + // ... other common middleware (e.g., session, logging) ... + // The output view of the last common middleware must match + // the input view expected by `routingDispatcher`. + .build(routingDispatcher); // `routingDispatcher` uses UserContextView + + // This `requestHandler` can now be used with Relic's server mechanism. + // e.g., relicServe(requestHandler, ...); +} +``` + +### 8.6. Implications + +* **Separation of Concerns**: Common middleware (auth, logging, sessions) are managed by the `PipelineBuilder`, preparing a general-purpose typed context. The `routingDispatcher` then handles routing-specific concerns and further context enrichment (route parameters) for the final endpoint handlers. +* **Type Safety End-to-End**: Endpoint handlers receive a context view that is guaranteed by the type system to contain all necessary data from both the common pipeline and the routing process. +* **Flexibility**: This pattern allows different sets of common middleware to be composed for distinct parts of an application. By creating multiple `PipelineBuilder` instances, each tailored with specific middleware and culminating in a different routing dispatcher (or final handler), an application can support varied requirements across its modules. + + For example, an application might have: + * An `/api/v1` section with `apiAuthMiddleware` leading to an API router, producing an `ApiUserContextView`. The `PipelineBuilder.build(...)` for this would result in an `apiV1RequestHandler: FutureOr Function(NewContext)`. + * An `/admin` section with `adminAuthMiddleware` and `sessionMiddleware` leading to an admin router, producing an `AdminSessionContextView`. This would result in an `adminRequestHandler: FutureOr Function(NewContext)`. + * A `/public` section with `cachingMiddleware` leading to a simpler router, using a `BaseContextView`. This would result in a `publicRequestHandler: FutureOr Function(NewContext)`. + + A top-level Relic `Router Function(NewContext)>` can then be used to select the appropriate pre-built pipeline handler based on path prefixes (e.g., requests to `/api/v1/**` lead to invoking `apiV1RequestHandler`). The main server entry point would create the initial `NewContext`, look up the target pipeline handler using this top-level router, and then pass the `NewContext` to the chosen handler. This top-level router doesn't deal with the typed context views itself but delegates to handlers that encapsulate their own typed pipelines. This maintains type safety within each specialized pipeline while allowing for a clean, router-based architecture at the highest level. *See Appendix A for a conceptual code sketch illustrating this top-level routing approach.* + +## 9. General Considerations + +* **PipelineBuilder Complexity**: The implementation of `PipelineBuilder`, especially its generic typing, is somewhat complex, but this complexity is encapsulated for the end-user. +* **Boilerplate for `ContextProperty` and Views**: Each new piece of context data requires defining a `ContextProperty` instance and corresponding view methods. However, this is more structured and less error-prone than raw `Expando` usage. +* **Learning Curve**: Developers using the framework will need to understand context views, `ContextProperty`, the role of `requestToken`, the pipeline builder, and the implications of the new middleware paradigm. +* **Discipline with `requestToken`**: The `ContextProperty` helper ensures that data is keyed off the stable `token` within the `RequestContext`, mitigating direct misuse of `Expando`s with transient `RequestContext` instances themselves as keys. +* **Middleware Return Types**: Middleware authors must be careful to return the correct context view type that accurately reflects the data they've attached via `ContextProperty` and the `requestToken`. + + +## Appendix A: Conceptual Code Example for Top-Level Routing + +This appendix provides a conceptual, runnable (with stubs) Dart code sketch to illustrate how different `PipelineBuilder` instances can create specialized request handling chains, and how a top-level router can direct traffic to the appropriate chain, all starting with a common `NewContext`. See [appendix_a.dart](appendix_a.dart). diff --git a/design/appendix_a.dart b/design/appendix_a.dart new file mode 100644 index 00000000..0c0fe4cf --- /dev/null +++ b/design/appendix_a.dart @@ -0,0 +1,246 @@ +// ignore_for_file: avoid_print + +import 'dart:async'; +import 'package:relic/src/router/router.dart'; + +// === Core Stubs (simplified) === +class Request { + final Uri uri; + final Method method; + final Map headers; + Request({required this.uri, required this.method, this.headers = const {}}); +} + +class Response { + final int statusCode; + final String body; + Response(this.statusCode, this.body); + + static Response ok(final String body) => Response(200, body); + static Response notFound(final String body) => Response(404, body); + static Response unauthorized(final String body) => Response(401, body); +} + +class RequestContext { + final Request request; + final Object token; // Stable unique token + RequestContext(this.request, this.token); +} + +class NewContext extends RequestContext { + NewContext(super.request, super.token); +} + +// === ContextProperty and Views Stubs === +class ContextProperty { + final Expando _expando; + final String? _debugName; + + ContextProperty([this._debugName]) : _expando = Expando(_debugName); + T get(final RequestContext ctx) { + final val = _expando[ctx.token]; + if (val == null) { + throw StateError('Property ${_debugName ?? T.toString()} not found'); + } + return val; + } + + void set(final RequestContext ctx, final T val) => _expando[ctx.token] = val; +} + +extension type BaseContextView(RequestContext _relicContext) { + Request get request => _relicContext.request; +} + +// User data and view +class User { + final String id; + final String name; + User(this.id, this.name); +} + +final _userProperty = ContextProperty('user'); +extension type UserContextView(RequestContext _relicContext) + implements BaseContextView { + User get user => _userProperty.get(_relicContext); + void attachUser(final User user) => _userProperty.set(_relicContext, user); +} + +// Admin data and view +class AdminRole { + final String roleName; + AdminRole(this.roleName); +} + +final _adminRoleProperty = ContextProperty('admin_role'); +extension type AdminContextView(RequestContext _relicContext) + implements UserContextView { + // Admin also has User + AdminRole get adminRole => _adminRoleProperty.get(_relicContext); + void attachAdminRole(final AdminRole role) => + _adminRoleProperty.set(_relicContext, role); +} + +// === PipelineBuilder Stub === +class PipelineBuilder { + final TOutView Function(TInView) _chain; + PipelineBuilder._(this._chain); + + static PipelineBuilder start() { + return PipelineBuilder._((final BaseContextView view) => view); + } + + PipelineBuilder add( + final TNextOutView Function(TOutView currentView) middleware, + ) { + return PipelineBuilder._( + (final TInView initialView) { + final previousOutput = _chain(initialView); + return middleware(previousOutput); + }); + } + + FutureOr Function(NewContext initialContext) build( + final FutureOr Function(TOutView finalView) handler, + ) { + final TOutView Function(BaseContextView) builtChain = + _chain as TOutView Function(BaseContextView); + return (final NewContext initialContext) { + final initialView = BaseContextView(initialContext) + as TInView; // Cast for the chain start + final finalView = builtChain(initialView); + return handler(finalView); + }; + } +} + +// === Placeholder Middleware === +// API Auth: Adds User, returns UserContextView +UserContextView apiAuthMiddleware(final BaseContextView inputView) { + print('API Auth Middleware Running for ${inputView.request.uri.path}'); + if (inputView.request.headers['X-API-Key'] == 'secret-api-key') { + final userView = UserContextView(inputView._relicContext); + userView.attachUser(User('api_user_123', 'API User')); + return userView; + } + throw Response(401, 'API Key Required'); // Short-circuiting via exception +} + +// Admin Auth: Adds User and AdminRole, returns AdminContextView +AdminContextView adminAuthMiddleware(final BaseContextView inputView) { + print('Admin Auth Middleware Running for ${inputView.request.uri.path}'); + if (inputView.request.headers['X-Admin-Token'] == + 'super-secret-admin-token') { + final userView = UserContextView(inputView._relicContext); + userView.attachUser(User('admin_user_007', 'Admin User')); + + final adminView = AdminContextView(inputView._relicContext); + adminView.attachAdminRole(AdminRole('super_admin')); + return adminView; + } + throw Response(401, 'Admin Token Required'); +} + +T generalLoggingMiddleware(final T inputView) { + // Weird analyzer bug inputView cannot be null here. + // Compiler and interpreter don't complain. Trying: + // final req = inputView!.request; + // won't work ¯\_(ツ)_/¯ + // ignore: unchecked_use_of_nullable_value + final req = inputView.request; + print('Logging: ${req.method} ${req.uri.path}'); + return inputView; +} + +// === Endpoint Handlers === +FutureOr handleApiUserDetails(final UserContextView context) { + print('Handling API User Details for ${context.user.name}'); + return Response.ok('API User: ${context.user.name} (id: ${context.user.id})'); +} + +FutureOr handleAdminDashboard(final AdminContextView context) { + print( + 'Handling Admin Dashboard for ${context.user.name} (${context.adminRole.roleName})'); + return Response.ok( + 'Admin: ${context.user.name}, Role: ${context.adminRole.roleName}'); +} + +FutureOr handlePublicInfo(final BaseContextView context) { + print('Handling Public Info for ${context.request.uri.path}'); + return Response.ok('This is public information.'); +} + +typedef Handler = FutureOr Function(NewContext); + +void main() async { + // === 1. Build Specialized Pipeline Handlers === + final apiHandler = PipelineBuilder.start() + .add(generalLoggingMiddleware) + .add(apiAuthMiddleware) + .build(handleApiUserDetails); + + final adminHandler = PipelineBuilder.start() + .add(generalLoggingMiddleware) + .add(adminAuthMiddleware) + .build(handleAdminDashboard); + + final publicHandler = PipelineBuilder.start() + .add(generalLoggingMiddleware) + .build(handlePublicInfo); + + // === 2. Configure Top-Level Router === + final topLevelRouter = Router() + ..any('/api/users/**', apiHandler) + ..any('/admin/dashboard/**', adminHandler) + ..any('/public/**', publicHandler); + + // === 3. Main Server Request Handler === + FutureOr mainServerRequestHandler(final Request request) { + final initialContext = NewContext(request, Object()); + print('\nProcessing ${request.method} ${request.uri.path}'); + + try { + final targetPipelineHandler = + topLevelRouter.lookup(request.method, request.uri.path)?.value; + + if (targetPipelineHandler != null) { + return targetPipelineHandler(initialContext); + } else { + print('No top-level route matched.'); + return Response.notFound('Service endpoint not found.'); + } + } on Response catch (e) { + print('Request short-circuited with response: ${e.statusCode}'); + return e; + } catch (e) { + print('Unhandled error: $e'); + return Response(500, 'Internal Server Error'); + } + } + + // === Simulate some requests === + final requests = [ + Request( + uri: Uri.parse('/api/users/123'), + method: Method.get, + headers: {'X-API-Key': 'secret-api-key'}, + ), + Request( + uri: Uri.parse('/api/users/456'), + method: Method.get, + headers: {'X-API-Key': 'wrong-key'}, + ), + Request( + uri: Uri.parse('/admin/dashboard'), + method: Method.get, + headers: {'X-Admin-Token': 'super-secret-admin-token'}, + ), + Request(uri: Uri.parse('/public/info'), method: Method.get), + Request(uri: Uri.parse('/unknown/path'), method: Method.get), + ]; + + for (final req in requests) { + final res = await mainServerRequestHandler(req); + print('Response for ${req.uri.path}: ${res.statusCode} - ${res.body}'); + } +} diff --git a/lib/src/router/normalized_path.dart b/lib/src/router/normalized_path.dart index ce64eaea..2abdd2b3 100644 --- a/lib/src/router/normalized_path.dart +++ b/lib/src/router/normalized_path.dart @@ -1,3 +1,5 @@ +import 'package:meta/meta.dart'; + import 'lru_cache.dart'; /// Represents a URL path that has been normalized. @@ -9,6 +11,7 @@ import 'lru_cache.dart'; /// /// Instances are interned using an LRU cache for efficiency, meaning identical /// normalized paths will often share the same object instance. +@immutable class NormalizedPath { /// Cache of interned instances static var interned = LruCache(10000); @@ -20,6 +23,9 @@ class NormalizedPath { /// Private constructor to create an instance with already normalized segments. NormalizedPath._(this.segments); + /// Empty normalized path instance. + static NormalizedPath empty = NormalizedPath._(const []); + /// Creates a [NormalizedPath] from a given [path] string. /// /// The provided [path] will be normalized by resolving `.` and `..` segments @@ -60,8 +66,14 @@ class NormalizedPath { /// /// The [start] parameter specifies the starting segment index (inclusive). /// The optional [end] parameter specifies the ending segment index (exclusive). - NormalizedPath subPath(final int start, [final int? end]) => - NormalizedPath._(segments.sublist(start, end)); + NormalizedPath subPath(final int start, [int? end]) { + end ??= length; + if (start == end) return NormalizedPath.empty; + if (start == 0 && end == length) { + return this; // since NormalizedPath is immutable + } + return NormalizedPath._(segments.sublist(start, end)); + } /// The number of segments in this path int get length => segments.length; diff --git a/lib/src/router/path_trie.dart b/lib/src/router/path_trie.dart index dd26885a..21d84c4a 100644 --- a/lib/src/router/path_trie.dart +++ b/lib/src/router/path_trie.dart @@ -1,15 +1,26 @@ import 'normalized_path.dart'; +typedef Parameters = Map; + /// Represents the result of a route lookup. final class LookupResult { /// The value associated with the matched route. final T value; /// A map of parameter names to their extracted values from the path. - final Map parameters; + final Parameters parameters; + + /// The normalized path that was matched. + final NormalizedPath matched; + + /// If a match, does not consume the full path, then stores the [remaining] + /// + /// This can only happen with a path that ends with a tail segment `/**`, + /// otherwise it will be empty. + final NormalizedPath remaining; /// Creates a [LookupResult] with the given [value] and [parameters]. - const LookupResult(this.value, this.parameters); + const LookupResult(this.value, this.parameters, this.matched, this.remaining); } /// A node within the path trie. @@ -18,10 +29,7 @@ final class _TrieNode { final Map> children = {}; /// Parameter definition associated with this node, if any. - /// - /// Stores the parameter name and the child node that represents the - /// parameterized path segment. - _Parameter? parameter; + _DynamicSegment? dynamicSegment; /// The value associated with the route ending at this node. /// @@ -29,12 +37,26 @@ final class _TrieNode { T? value; } -typedef _Parameter = ({_TrieNode node, String name}); +sealed class _DynamicSegment { + final node = _TrieNode(); +} + +/// Stores the parameter [name] and the child [node] that represents the +/// parameterized path segment. +final class _Parameter extends _DynamicSegment { + final String name; + + _Parameter(this.name); +} + +final class _Wildcard extends _DynamicSegment {} + +final class _Tail extends _DynamicSegment {} /// A Trie (prefix tree) data structure optimized for matching URL paths. /// -/// Supports literal segments and parameterized segments (e.g., `:id`). Allows -/// associating a value of type [T] with each complete path. +/// Supports literal segments, parameterized segments (e.g., `:id`), wildcard segments (`*`), +/// and tail segments (`**`). Allows associating a value of type [T] with each complete path. final class PathTrie { // Note: not final since we update in attach var _root = _TrieNode(); @@ -52,13 +74,12 @@ final class PathTrie { final currentNode = _build(normalizedPath); // Mark the end node and handle potential overwrites if (currentNode.value != null) { - throw ArgumentError( - 'Value already registered: ' - 'Existing: "${currentNode.value}" ' - 'New: "$value" ' - 'for path $normalizedPath', - 'normalizedPath', - ); + throw ArgumentError.value( + normalizedPath, + 'normalizedPath', + 'Value already registered: ' + 'Existing: "${currentNode.value}" ' + 'New: "$value"'); } currentNode.value = value; } @@ -150,12 +171,32 @@ final class PathTrie { final segments = normalizedPath.segments; _TrieNode currentNode = _root; + // Helper function + @pragma('vm:prefer-inline') + _TrieNode? nextIf>( + final _DynamicSegment? dynamicSegment) { + if (dynamicSegment != null && dynamicSegment is U) { + return dynamicSegment.node; + } + return null; + } + for (final segment in segments) { var nextNode = currentNode.children[segment]; - if (nextNode == null && segment.startsWith(':')) { - final parameter = currentNode.parameter; - if (parameter != null && parameter.name == segment.substring(1)) { - nextNode = parameter.node; + if (nextNode == null) { + final dynamicSegment = currentNode.dynamicSegment; + if (segment == '**') { + // Handle tail segment + nextNode = nextIf<_Tail>(dynamicSegment); + } else if (segment == '*') { + // Handle wildcard segment + nextNode = nextIf<_Wildcard>(dynamicSegment); + } else if (segment.startsWith(':')) { + // Handle parameter segment + final parameter = dynamicSegment as _Parameter?; + if (parameter != null && parameter.name == segment.substring(1)) { + nextNode = parameter.node; + } } } if (nextNode == null) return null; // early exit @@ -169,28 +210,63 @@ final class PathTrie { final segments = normalizedPath.segments; _TrieNode currentNode = _root; - for (final segment in segments) { - if (segment.startsWith(':')) { + // Helper function + @pragma('vm:prefer-inline') + void isA>( + final _DynamicSegment? dynamicSegment, final int segmentNo) { + if (dynamicSegment != null && dynamicSegment is! U) { + normalizedPath.raiseInvalidSegment( + segmentNo, + 'Conflicting segment type at the same level: ' + 'Existing: ${dynamicSegment.runtimeType}, ' + 'New: $U'); + } + } + + for (int i = 0; i < segments.length; i++) { + final segment = segments[i]; + final dynamicSegment = currentNode.dynamicSegment; + + if (segment.startsWith('**')) { + // Handle tail segment + if (segment != '**') { + normalizedPath.raiseInvalidSegment(i, 'Starts with "**"'); + } + if (i < segments.length - 1) { + normalizedPath.raiseInvalidSegment(i, + 'Tail segment (**) must be the last segment in the path definition.'); + } + isA<_Tail>(dynamicSegment, i); + currentNode = (currentNode.dynamicSegment ??= _Tail()).node; + } else if (segment.startsWith('*')) { + // Handle wildcard segment + if (segment != '*') { + normalizedPath.raiseInvalidSegment(i, 'Starts with "*"'); + } + isA<_Wildcard>(dynamicSegment, i); + currentNode = (currentNode.dynamicSegment ??= _Wildcard()).node; + } else if (segment.startsWith(':')) { + // Handle parameter segment + isA<_Parameter>(dynamicSegment, i); final paramName = segment.substring(1).trim(); if (paramName.isEmpty) { - throw ArgumentError.value(normalizedPath, 'normalizedPath', - 'Parameter name cannot be empty'); + normalizedPath.raiseInvalidSegment( + i, 'Parameter name cannot be empty'); } // Ensure parameter child exists and handle name conflicts - var parameter = currentNode.parameter; + var parameter = dynamicSegment as _Parameter?; if (parameter == null) { - parameter = (node: _TrieNode(), name: paramName); + parameter = _Parameter(paramName); } else if (parameter.name != paramName) { // Throw an error if a different parameter name already exists at this level. - throw ArgumentError( + normalizedPath.raiseInvalidSegment( + i, 'Conflicting parameter names at the same level: ' - 'Existing: ":${parameter.name}", ' - 'New: ":$paramName" ' - 'for path $normalizedPath', - 'normalizedPath', + 'Existing: ":${parameter.name}", ' + 'New: ":$paramName"', ); } - currentNode.parameter = parameter; + currentNode.dynamicSegment = parameter; currentNode = parameter.node; } else { // Handle literal segment @@ -214,7 +290,7 @@ final class PathTrie { /// /// Throws an [ArgumentError] if: /// - The node at [normalizedPath] has a value, and the root node of [trie] has as well. - /// - Both nodes has an associated parameter. + /// - Both nodes has an associated dynamic segment. /// - There are overlapping children between the nodes. void attach(final NormalizedPath normalizedPath, final PathTrie trie) { final node = trie._root; @@ -224,7 +300,7 @@ final class PathTrie { throw ArgumentError('Conflicting values'); } - if (currentNode.parameter != null && node.parameter != null) { + if (currentNode.dynamicSegment != null && node.dynamicSegment != null) { throw ArgumentError('Conflicting parameters'); } @@ -236,7 +312,7 @@ final class PathTrie { // No conflicts so safe to update currentNode.value ??= node.value; - currentNode.parameter ??= node.parameter; + currentNode.dynamicSegment ??= node.dynamicSegment; currentNode.children.addAll(node.children); trie._root = currentNode; } @@ -253,25 +329,49 @@ final class PathTrie { _TrieNode currentNode = _root; final parameters = {}; - for (final segment in segments) { + int i = 0; + for (; i < segments.length; i++) { + final segment = segments[i]; final child = currentNode.children[segment]; if (child != null) { // Prioritize literal match currentNode = child; } else { - final parameter = currentNode.parameter; - if (parameter != null) { - // Match parameter + final dynamicSegment = currentNode.dynamicSegment; + if (dynamicSegment == null) return null; // no match + currentNode = dynamicSegment.node; + if (dynamicSegment case final _Parameter parameter) { parameters[Symbol(parameter.name)] = segment; - currentNode = parameter.node; - } else { - // No match - return null; } + if (dynamicSegment is _Tail) break; // possible early match } } - final value = currentNode.value; - return value != null ? LookupResult(value, parameters) : null; + T? value = currentNode.value; + final matchedPath = normalizedPath.subPath(0, i); + final remainingPath = normalizedPath.subPath(i); + + // If no value found after iterating through all segments, check if + // currentNode has a tail. If so proceed one more step. This handles cases + // like /archive/** matching /archive, where remainingPath would be empty. + if (value == null && i == segments.length) { + final dynamicSegment = currentNode.dynamicSegment; + if (dynamicSegment is _Tail) { + currentNode = dynamicSegment.node; + value = currentNode.value; + } + } + + return value != null + ? LookupResult(value, parameters, matchedPath, remainingPath) + : null; + } +} + +extension on NormalizedPath { + Never raiseInvalidSegment(final int segmentNo, final String message, + {final String name = 'normalizedPath'}) { + throw ArgumentError.value(this, name, + 'Segment no $segmentNo: "${segments[segmentNo]}" is invalid. $message'); } } diff --git a/lib/src/router/router.dart b/lib/src/router/router.dart index 9b6c7e0c..345e4635 100644 --- a/lib/src/router/router.dart +++ b/lib/src/router/router.dart @@ -96,7 +96,14 @@ final class Router { // Try static cache first final value = _staticCache[normalizedPath]?.find(method); - if (value != null) return LookupResult(value, const {}); + if (value != null) { + return LookupResult( + value, + const {}, + normalizedPath, + NormalizedPath.empty, + ); + } // Fall back to trie final entry = _allRoutes.lookup(normalizedPath); @@ -110,7 +117,12 @@ final class Router { _staticCache[normalizedPath] = entry.value; } - return LookupResult(route, entry.parameters); + return LookupResult( + route, + entry.parameters, + entry.matched, + entry.remaining, + ); } } diff --git a/pubspec.yaml b/pubspec.yaml index 69a3a907..400ec888 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: collection: ^1.18.0 convert: ^3.1.1 http_parser: ^4.0.2 + meta: ^1.16.0 mime: ">=1.0.6 <3.0.0" path: ^1.8.3 stack_trace: ^1.10.0 @@ -31,6 +32,5 @@ dev_dependencies: # our direct dependencies indicate. file: ^7.0.0 # ignore: sort_pub_dependencies frontend_server_client: ^4.0.0 - meta: ^1.9.1 pub_semver: ^2.1.4 watcher: ^1.1.0 diff --git a/test/router/path_trie_crud_test.dart b/test/router/path_trie_crud_test.dart index 86b95526..f5c9c1aa 100644 --- a/test/router/path_trie_crud_test.dart +++ b/test/router/path_trie_crud_test.dart @@ -175,6 +175,61 @@ void main() { expect(() => trie.update(pathDefinition, 1), throwsArgumentError); expect(trie.lookup(NormalizedPath('/articles/any')), isNull); }); + + test( + 'Given a trie with an existing wildcard path /data/* and value, ' + 'when update is called for /data/* with a new value, ' + 'then lookup for a matching path returns the new value', () { + final pathDefinition = NormalizedPath('/data/*'); + trie.add(pathDefinition, 1); + + trie.update(pathDefinition, 2); + + // Verify by looking up a path that would match the wildcard definition + final result = trie.lookup(NormalizedPath('/data/something')); + expect(result, isNotNull); + expect(result!.value, equals(2)); + expect( + result.parameters, isEmpty); // Wildcards don't produce parameters + expect(result.matched.toString(), '/data/something'); + expect(result.remaining.segments, isEmpty); + }); + + test( + 'Given a trie, ' + 'when update is called for a wildcard path /data/* that does not exist as a defined route, ' + 'then an ArgumentError is thrown', () { + final pathDefinition = NormalizedPath('/data/*'); + expect(() => trie.update(pathDefinition, 1), throwsArgumentError); + expect(trie.lookup(NormalizedPath('/data/anything')), isNull); + }); + + test( + 'Given a trie with an existing tail path /files/** and value, ' + 'when update is called for /files/** with a new value, ' + 'then lookup for a matching path returns the new value', () { + final pathDefinition = NormalizedPath('/files/**'); + trie.add(pathDefinition, 1); + + trie.update(pathDefinition, 2); + + // Verify by looking up a path that would match the tail definition + final result = trie.lookup(NormalizedPath('/files/a/b.txt')); + expect(result, isNotNull); + expect(result!.value, equals(2)); + expect(result.parameters, isEmpty); + expect(result.matched.toString(), '/files'); + expect(result.remaining.toString(), '/a/b.txt'); + }); + + test( + 'Given a trie, ' + 'when update is called for a tail path /files/** that does not exist as a defined route, ' + 'then an ArgumentError is thrown', () { + final pathDefinition = NormalizedPath('/files/**'); + expect(() => trie.update(pathDefinition, 1), throwsArgumentError); + expect(trie.lookup(NormalizedPath('/files/anything/else')), isNull); + }); }); group('remove', () { @@ -300,6 +355,71 @@ void main() { expect(trie.lookup(pathABC), isNull); expect(trie.lookup(pathABD)?.value, 2); }); + + test( + 'Given a trie with an existing wildcard path /data/* and value, ' + 'when remove is called for /data/*, ' + 'then the value is removed and lookup for matching paths returns null', + () { + final pathDefinition = NormalizedPath('/data/*'); + trie.add(pathDefinition, 10); + trie.add(NormalizedPath('/data/fixed'), 20); // Sibling literal + + final removedValue = trie.remove(pathDefinition); + + expect(removedValue, equals(10)); + expect(trie.lookup(NormalizedPath('/data/any')), isNull, + reason: 'Wildcard path should be removed.'); + expect(trie.lookup(NormalizedPath('/data/fixed'))?.value, 20, + reason: 'Sibling literal path should be unaffected.'); + }); + + test( + 'Given a trie, ' + 'when remove is called for a wildcard path /data/* that does not exist as a defined route, ' + 'then null is returned and no other paths are affected', () { + final pathDefinition = NormalizedPath('/data/*'); + trie.add( + NormalizedPath('/other/*'), 1); // Add a different wildcard path + + final removedValue = trie.remove(pathDefinition); + + expect(removedValue, isNull); + expect(trie.lookup(NormalizedPath('/other/something'))?.value, 1); + }); + + test( + 'Given a trie with an existing tail path /files/** and value, ' + 'when remove is called for /files/**, ' + 'then the value is removed and lookup for matching paths returns null', + () { + final pathDefinition = NormalizedPath('/files/**'); + trie.add(pathDefinition, 30); + trie.add(NormalizedPath('/files/specific/file.txt'), + 40); // More specific child + + final removedValue = trie.remove(pathDefinition); + + expect(removedValue, equals(30)); + expect(trie.lookup(NormalizedPath('/files/a/b/c')), isNull, + reason: 'Tail path should be removed.'); + final specificLookup = + trie.lookup(NormalizedPath('/files/specific/file.txt')); + expect(specificLookup?.value, 40, + reason: 'More specific path should remain'); + }); + + test( + 'Given a trie, ' + 'when remove is called for a tail path /files/** that does not exist as a defined route, ' + 'then null is returned and no other paths are affected', () { + final pathDefinition = NormalizedPath('/files/**'); + trie.add(NormalizedPath('/archive/**'), 1); // Add a different tail path + + final removedValue = trie.remove(pathDefinition); + expect(removedValue, isNull); + expect(trie.lookup(NormalizedPath('/archive/some/file'))?.value, 1); + }); }); }); } diff --git a/test/router/path_trie_tail_test.dart b/test/router/path_trie_tail_test.dart new file mode 100644 index 00000000..1e0ecd52 --- /dev/null +++ b/test/router/path_trie_tail_test.dart @@ -0,0 +1,265 @@ +import 'package:relic/src/router/normalized_path.dart'; +import 'package:relic/src/router/path_trie.dart'; +import 'package:test/test.dart'; + +void main() { + group('PathTrie Tail (**) Matching', () { + late PathTrie trie; + + setUp(() { + trie = PathTrie(); + }); + + test( + 'Given a trie with path /static/**, ' + 'when /static/css/style.css is looked up, ' + 'then it matches with correct value and remaining path', () { + trie.add(NormalizedPath('/static/**'), 1); + final result = trie.lookup(NormalizedPath('/static/css/style.css')); + expect(result, isNotNull); + expect(result!.value, 1); + expect(result.parameters, isEmpty); + expect(result.matched.path, '/static'); + expect(result.remaining.path, '/css/style.css'); + }); + + test( + 'Given a trie with path /files/**, ' + 'when /files/a/b/c/doc.txt (multiple segments for tail) is looked up, ' + 'then it matches with correct value and remaining path', () { + trie.add(NormalizedPath('/files/**'), 1); + final result = trie.lookup(NormalizedPath('/files/a/b/c/doc.txt')); + expect(result, isNotNull); + expect(result!.value, 1); + expect(result.parameters, isEmpty); + expect(result.matched.path, '/files'); + expect(result.remaining.path, '/a/b/c/doc.txt'); + }); + + test( + 'Given a trie with path /archive/**, ' + 'when /archive/ (ends where tail starts) is looked up, ' + 'then it matches with an empty remaining path', () { + trie.add(NormalizedPath('/archive/**'), 1); + final result = trie.lookup(NormalizedPath('/archive/')); + expect(result, isNotNull); + expect(result!.value, 1); + expect(result.parameters, isEmpty); + expect(result.matched.path, '/archive'); + expect(result.remaining.segments, isEmpty); + }); + + test( + 'Given a trie with path /exact/**, ' + 'when /exact (no trailing slash, ends where tail starts) is looked up, ' + 'then it matches with an empty remaining path', () { + trie.add(NormalizedPath('/exact/**'), 1); + final result = trie.lookup(NormalizedPath('/exact')); + expect(result, isNotNull); + expect(result!.value, 1); + expect(result.parameters, isEmpty); + expect(result.matched.path, '/exact'); + expect(result.remaining.segments, isEmpty); + }); + + test( + 'Given a trie with path /**, ' + 'when /any/path/anywhere is looked up, ' + 'then it matches with an empty matched path and correct remaining path', + () { + trie.add(NormalizedPath('/**'), 1); + final result = trie.lookup(NormalizedPath('/any/path/anywhere')); + expect(result, isNotNull); + expect(result!.value, 1); + expect(result.parameters, isEmpty); + expect(result.matched.segments, isEmpty); + expect(result.remaining.path, '/any/path/anywhere'); + }); + + group('Root path (/) matching with root tail (/**) interactions', () { + setUp(() { + // Ensures a fresh trie for each scenario in this group + trie = PathTrie(); + }); + + test( + 'Given a trie with only path /** defined, ' + 'when the root path / is looked up,' + 'then /** matches with its value and empty matched/remaining paths', + () { + trie.add(NormalizedPath('/**'), 1); + final result = trie.lookup(NormalizedPath('/')); + expect(result, isNotNull, + reason: 'Lookup for / should find /** if / has no value'); + expect(result!.value, 1); + expect(result.matched.segments, isEmpty, + reason: 'Matched path for /** lookup of / should be empty'); + expect(result.remaining.segments, isEmpty, + reason: 'Remaining path for /** lookup of / should be empty'); + }); + + test( + 'Given a trie with only path / defined, ' + 'when the root path / is looked up, ' + 'then / matches with its value and empty matched/remaining paths', + () { + trie.add(NormalizedPath('/'), 2); + final result = trie.lookup(NormalizedPath('/')); + expect(result, isNotNull); + expect(result!.value, 2); + expect(result.matched.segments, isEmpty); + expect(result.remaining.segments, isEmpty); + }); + + test( + 'Given a trie with path / and path /** defined, ' + 'when the root path / is looked up, ' + 'then the literal path / takes precedence with its value', () { + trie.add(NormalizedPath('/'), 2); + trie.add(NormalizedPath('/**'), 3); // /** has a different value + + final result = trie.lookup(NormalizedPath('/')); + expect(result, isNotNull, + reason: 'Lookup for / should prefer value on / over /**'); + expect(result!.value, 2, reason: 'Value from / should be preferred'); + expect(result.matched.segments, isEmpty); + expect(result.remaining.segments, isEmpty); + }); + + test( + 'Given a trie with path / and path /** defined, ' + 'when a sub-path like /some/path is looked up, ' + 'then path /** matches with its value and correct remaining path', + () { + trie.add(NormalizedPath('/'), 2); + trie.add(NormalizedPath('/**'), 3); + + final result = trie.lookup(NormalizedPath('/some/path')); + expect(result, isNotNull, + reason: '/** should still match longer paths'); + expect(result!.value, 3, + reason: 'Value from /** should match longer paths'); + expect(result.matched.segments, isEmpty); + expect(result.remaining.path, '/some/path'); + }); + }); + + test( + 'Given a trie with /assets/js/app.js and /assets/**, ' + 'when paths are looked up, ' + 'then literal is preferred and tail matches remaining', () { + trie.add(NormalizedPath('/assets/js/app.js'), 1); + trie.add(NormalizedPath('/assets/**'), 2); + + final literalResult = trie.lookup(NormalizedPath('/assets/js/app.js')); + expect(literalResult, isNotNull); + expect(literalResult!.value, 1); + expect(literalResult.matched.path, '/assets/js/app.js'); + expect(literalResult.remaining.segments, isEmpty); + + final tailResult = trie.lookup(NormalizedPath('/assets/img/logo.png')); + expect(tailResult, isNotNull); + expect(tailResult!.value, 2); + expect(tailResult.matched.path, '/assets'); + expect(tailResult.remaining.path, '/img/logo.png'); + }); + + test( + 'Given a trie with /foo/bar/** and /foo/**, ' + 'when paths are looked up, ' + 'then the more specific /foo/bar/** is chosen over /foo/**', () { + trie.add(NormalizedPath('/foo/bar/**'), 1); + trie.add(NormalizedPath('/foo/**'), 2); + + final resSpecific = trie.lookup(NormalizedPath('/foo/bar/baz/qux')); + expect(resSpecific, isNotNull); + expect(resSpecific!.value, 1); + expect(resSpecific.matched.path, '/foo/bar'); + expect(resSpecific.remaining.path, '/baz/qux'); + + final resGeneral = trie.lookup(NormalizedPath('/foo/other/path')); + expect(resGeneral, isNotNull); + expect(resGeneral!.value, 2); + expect(resGeneral.matched.path, '/foo'); + expect(resGeneral.remaining.path, '/other/path'); + }); + + test( + 'Given a trie with path /user/:id/files/**, ' + 'when /user/42/files/docs/report.pdf is looked up, ' + 'then it matches with correct parameter and remaining path', () { + trie.add(NormalizedPath('/user/:id/files/**'), 1); + final result = + trie.lookup(NormalizedPath('/user/42/files/docs/report.pdf')); + expect(result, isNotNull); + expect(result!.value, 1); + expect(result.parameters, equals({#id: '42'})); + expect(result.matched.path, '/user/42/files'); + expect(result.remaining.path, '/docs/report.pdf'); + }); + + test( + 'Given a trie with path /data/export/**, ' + 'when /data (shorter than prefix) is looked up, ' + 'then no match is found', () { + trie.add(NormalizedPath('/data/export/**'), 1); + expect(trie.lookup(NormalizedPath('/data')), isNull); + }); + + test( + 'Given an empty trie, ' + 'when adding a path like /**foo (tail not a full segment), ' + 'then an ArgumentError is thrown', () { + expect(() => trie.add(NormalizedPath('/downloads/**foo'), 1), + throwsArgumentError); + }); + + group('Tail and Other Segment interaction validation', () { + test( + 'Given a trie with /test/**, ' + 'when adding /test/:id (parameter after tail at same level), ' + 'then an ArgumentError is thrown', () { + trie.add(NormalizedPath('/test/**'), 1); + expect(() => trie.add(NormalizedPath('/test/:id'), 2), + throwsArgumentError); + }); + + test( + 'Given a trie with /test/:id, ' + 'when adding /test/** (tail after parameter at same level), ' + 'then an ArgumentError is thrown', () { + trie.add(NormalizedPath('/test/:id'), 1); + expect( + () => trie.add(NormalizedPath('/test/**'), 2), throwsArgumentError); + }); + + test( + 'Given a trie with /test/**, ' + 'when adding /test/* (wildcard after tail at same level), ' + 'then an ArgumentError is thrown', () { + trie.add(NormalizedPath('/test/**'), 1); + expect( + () => trie.add(NormalizedPath('/test/*'), 2), throwsArgumentError); + }); + + test( + 'Given a trie with /test/*, ' + 'when adding /test/** (tail after wildcard at same level), ' + 'then an ArgumentError is thrown', () { + trie.add(NormalizedPath('/test/*'), 1); + expect( + () => trie.add(NormalizedPath('/test/**'), 2), throwsArgumentError); + }); + + test( + 'Given an empty trie, ' + 'when adding a path /a/**/b/c (tail /** not as the last segment), ' + 'then an ArgumentError is thrown', () { + expect( + () => trie.add(NormalizedPath('/a/**/b/c'), 1), throwsArgumentError, + reason: + 'Tail segment /** must be the last segment in a path definition.'); + }); + }); + }); +} diff --git a/test/router/path_trie_wildcard_test.dart b/test/router/path_trie_wildcard_test.dart new file mode 100644 index 00000000..9ae31bac --- /dev/null +++ b/test/router/path_trie_wildcard_test.dart @@ -0,0 +1,154 @@ +import 'package:relic/src/router/normalized_path.dart'; +import 'package:relic/src/router/path_trie.dart'; +import 'package:test/test.dart'; + +void main() { + group('PathTrie Wildcard (*) Matching', () { + late PathTrie trie; + + setUp(() { + trie = PathTrie(); + }); + + test( + 'Given a trie with path /users/*/profile, ' + 'when /users/123/profile is looked up, ' + 'then it matches with correct value and paths', () { + trie.add(NormalizedPath('/users/*/profile'), 1); + final result = trie.lookup(NormalizedPath('/users/123/profile')); + expect(result, isNotNull); + expect(result!.value, 1); + expect(result.parameters, isEmpty); + expect(result.matched.path, '/users/123/profile'); + expect(result.remaining.segments, isEmpty); + }); + + test( + 'Given a trie with path /*/resource, ' + 'when /any/resource is looked up, ' + 'then it matches with correct value and paths', () { + trie.add(NormalizedPath('/*/resource'), 1); + final result = trie.lookup(NormalizedPath('/any/resource')); + expect(result, isNotNull); + expect(result!.value, 1); + expect(result.parameters, isEmpty); + expect(result.matched.path, '/any/resource'); + expect(result.remaining.segments, isEmpty); + }); + + test( + 'Given a trie with path /files/*, ' + 'when /files/image.jpg is looked up, ' + 'then it matches with correct value and paths', () { + trie.add(NormalizedPath('/files/*'), 1); + final result = trie.lookup(NormalizedPath('/files/image.jpg')); + expect(result, isNotNull); + expect(result!.value, 1); + expect(result.parameters, isEmpty); + expect(result.matched.path, '/files/image.jpg'); + expect(result.remaining.segments, isEmpty); + }); + + test( + 'Given a trie with path /a/*/c/*, ' + 'when /a/b/c/d is looked up, ' + 'then it matches with correct value and paths', () { + trie.add(NormalizedPath('/a/*/c/*'), 1); + final result = trie.lookup(NormalizedPath('/a/b/c/d')); + expect(result, isNotNull); + expect(result!.value, 1); + expect(result.parameters, isEmpty); + expect(result.matched.path, '/a/b/c/d'); + expect(result.remaining.segments, isEmpty); + }); + + test( + 'Given a trie with path /a/*/b, ' + 'when /a/b (fewer segments) is looked up, ' + 'then no match is found', () { + trie.add(NormalizedPath('/a/*/b'), 1); + expect(trie.lookup(NormalizedPath('/a/b')), isNull); + }); + + test( + 'Given a trie with /data/specific and /data/*, ' + 'when they are looked up, ' + 'then literal /data/specific is preferred over /data/*, and /data/* matches other segments', + () { + trie.add(NormalizedPath('/data/specific'), 1); + trie.add(NormalizedPath('/data/*'), 2); + final result = trie.lookup(NormalizedPath('/data/specific')); + expect(result, isNotNull); + expect(result!.value, 1); + + final wildResult = trie.lookup(NormalizedPath('/data/general')); + expect(wildResult, isNotNull); + expect(wildResult!.value, 2); + }); + + test( + 'Given a trie with path /assets/*, ' + 'when /assets/img/logo.png (wildcard part spans multiple segments) is looked up, ' + 'then no match is found', () { + trie.add(NormalizedPath('/assets/*'), 1); + expect(trie.lookup(NormalizedPath('/assets/img/logo.png')), isNull); + }); + + test( + 'Given a trie with path /api/:version/data/*, ' + 'when /api/v1/data/users is looked up, ' + 'then it matches with correct value, parameter, and paths', () { + trie.add(NormalizedPath('/api/:version/data/*'), 1); + final result = trie.lookup(NormalizedPath('/api/v1/data/users')); + expect(result, isNotNull); + expect(result!.value, 1); + expect(result.parameters, equals({#version: 'v1'})); + expect(result.matched.path, '/api/v1/data/users'); + expect(result.remaining.segments, isEmpty); + }); + + test( + 'Given a trie with path /a/b/*/d, ' + 'when /a/b/c (shorter) is looked up, ' + 'then no match is found', () { + trie.add(NormalizedPath('/a/b/*/d'), 1); + expect(trie.lookup(NormalizedPath('/a/b/c')), isNull); + }); + + test( + 'Given a trie with path /a/b/* (no tail), ' + 'when /a/b/c/d (longer) is looked up, ' + 'then no match is found', () { + trie.add(NormalizedPath('/a/b/*'), 1); + expect(trie.lookup(NormalizedPath('/a/b/c/d')), isNull); + }); + + test( + 'Given an empty trie, ' + 'when adding a path like /*foo/bar (wildcard not a full segment), ' + 'then an ArgumentError is thrown', () { + expect( + () => trie.add(NormalizedPath('/*foo/bar'), 1), throwsArgumentError); + }); + + group('Wildcard and Parameter interaction validation', () { + test( + 'Given a trie with /test/*, ' + 'when adding /test/:id (parameter after wildcard at same level), ' + 'then an ArgumentError is thrown', () { + trie.add(NormalizedPath('/test/*'), 1); + expect(() => trie.add(NormalizedPath('/test/:id'), 2), + throwsArgumentError); + }); + + test( + 'Given a trie with /test/:id, ' + 'when adding /test/* (wildcard after parameter at same level), ' + 'then an ArgumentError is thrown', () { + trie.add(NormalizedPath('/test/:id'), 1); + expect( + () => trie.add(NormalizedPath('/test/*'), 2), throwsArgumentError); + }); + }); + }); +}