-
Notifications
You must be signed in to change notification settings - Fork 8
docs: Design typed pipeline #74
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
6ad3141
510fb02
2571dfd
f1f0490
d7fe6cc
65f9e5f
87420ac
e253ee5
95f7ffc
935e0dd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
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> { | ||
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 And indeed when I remove the
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I did not mean removing the I can remove the implementation, get it to compile and then error at runtime.
|
||
// 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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think with the 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
We would have to wrap/modify the 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}'); | ||
} | ||
} |
There was a problem hiding this comment.
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).There was a problem hiding this comment.
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.