Skip to content
Draft
451 changes: 451 additions & 0 deletions design/DESIGN_typed_context_pipeline.md

Large diffs are not rendered by default.

246 changes: 246 additions & 0 deletions design/appendix_a.dart
Original file line number Diff line number Diff line change
@@ -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<String, String> 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<T extends Object> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can get behind this pattern.

I think such a declaration is very smart, because it gives you a unique identifier and here (unlike in riverpod etc.) it's only the helper / identifier instance and does not practically have any global state (as for the you need the specific request context / token).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, this pattern makes sense with the current paradigm as well.

final Expando<T> _expando;
final String? _debugName;

ContextProperty([this._debugName]) : _expando = Expando<T>(_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>('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<AdminRole>('admin_role');
extension type AdminContextView(RequestContext _relicContext)
implements UserContextView {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was surprised this would even compile, because the type does not conform to UserContextView.

And indeed when I remove the userView from adminAuthMiddleware, it still compiles, but then fails at runtime:

Unhandled error: Bad state: Property user not found

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I follow.

xtension type AdminContextView(RequestContext _relicContext) { // missing implements

will introduce a compile error in:

FutureOr<Response> 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}');
}

The meaning of implements for extension types are defined here.

In particular:

An extension type can only implement:
...

  • Another extension type that is valid on the same representation type. This allows you to reuse operations across multiple extension types (similar to multiple inheritance).

Copy link
Collaborator Author

@nielsenko nielsenko May 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I defined:

extension type AdminContextView._(RequestContext _relicContext)
    implements UserContextView {
  AdminContextView(UserContextView uv) : _relicContext = uv._relicContext;

  // Admin also has User
  AdminRole get adminRole => _adminRoleProperty.get(_relicContext);
  void attachAdminRole(final AdminRole role) =>
      _adminRoleProperty.set(_relicContext, role);
}

would that be better?

In general the example don't do full encapsulation. You could argue the ctor should take AdminRole as well and do the attach.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I follow.

I did not mean removing the implements keyword, but rather the implementation (of assigning the user ID inside the admin context view).

I can remove the implementation, get it to compile and then error at runtime.
(We discussed this already, just for completeness sake here.)

AdminContextView(UserContextView uv) in the constructor seems like a better pattern to me, pushing the implementation to be more correct. Sadly that only solves 1 layer of inheritance. If you wanted to build a combined view that had implements A, B that would not work as well (unless you take 2 constructor params and hope that they point fo the same context…).

// Admin also has User
AdminRole get adminRole => _adminRoleProperty.get(_relicContext);
void attachAdminRole(final AdminRole role) =>
_adminRoleProperty.set(_relicContext, role);
}

// === PipelineBuilder Stub ===
class PipelineBuilder<TInView extends BaseContextView, TOutView> {
final TOutView Function(TInView) _chain;
PipelineBuilder._(this._chain);

static PipelineBuilder<BaseContextView, BaseContextView> start() {
return PipelineBuilder._((final BaseContextView view) => view);
}

PipelineBuilder<TInView, TNextOutView> add<TNextOutView>(
final TNextOutView Function(TOutView currentView) middleware,
) {
return PipelineBuilder<TInView, TNextOutView>._(
(final TInView initialView) {
final previousOutput = _chain(initialView);
return middleware(previousOutput);
});
}

FutureOr<Response> Function(NewContext initialContext) build(
final FutureOr<Response> 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) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think with the AdminContext implementing the UserContext, this example is a bit too simple.

Because there it "makes sense" that one could implement the other.

But assume we had a "client language" context (which reads the HTTP header) instead. Then it would feel weird to that in the pipeline of

  .add(logger)
  .add(clientLanguage)
  .add(user)

We would have to wrap/modify the user middleware to also include the language, right?

Unless I am mistaken on this point, I think it's a bit of a code-smell if in order to add a new middleware I would have to change 2 places in tandem.

Maybe then it would be better if (for example, just a spitball) a router/server was an abstract class to instantiate for ones specific project, which would use mixins with all the middleware, and then the route could just depend on it (and routes provided from third party packages would require you to add that mixin to provide the right context before being able to "mount" them – or they use a subrouter which has those only for the affected routes).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 let's discuss

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<T extends BaseContextView>(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<Response> 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<Response> 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<Response> handlePublicInfo(final BaseContextView context) {
print('Handling Public Info for ${context.request.uri.path}');
return Response.ok('This is public information.');
}

typedef Handler = FutureOr<Response> 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<Handler>()
..any('/api/users/**', apiHandler)
..any('/admin/dashboard/**', adminHandler)
..any('/public/**', publicHandler);

// === 3. Main Server Request Handler ===
FutureOr<Response> 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}');
}
}
16 changes: 14 additions & 2 deletions lib/src/router/normalized_path.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'package:meta/meta.dart';

import 'lru_cache.dart';

/// Represents a URL path that has been normalized.
Expand All @@ -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<String, NormalizedPath>(10000);
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Loading