-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
fix(rest): use context.get() to resolve current controller #993
Conversation
9dbdf89
to
48629be
Compare
I have several reservations against the changes proposed here.
|
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.
Let's address my high-level comments above first.
Once we get to implementation details, I have one comment below.
Additionally, I'd like to see more test coverage for this change:
- A controller bound via
app.bind()
in the default TRANSIENT scope. (Even if we changeapp.controller()
to bind in CONTEXT scope, then it's still possible to bind TRANSIENT controller viaapp.bind()
directly.) - A controller bound via
app.bind()
inCONTEXT
(child context?) scope. - A controller route bound via
restServer.route
API, where the controller class is not bound.
UPDATED:
By test coverage, I mean tests specifically verifying whether this
is the same object as what's injected by @inject
.
@@ -499,7 +525,7 @@ describe('Routing', () => { | |||
} | |||
|
|||
function givenControllerInApp(app: Application, controller: ControllerClass) { | |||
app.controller(controller); | |||
app.controller(controller).inScope(BindingScope.CONTEXT); |
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.
IMO, all controllers should be scoped to the request context. Can you think of a use case where we would want multiple controller instances in a single request context?
Originally, I wanted to propose to add inScope
call to app.controller()
. On the second thought, I am not sure if it's a good idea - what happens when somebody calls app.get('controllers.ProductController')
, will future calls of requestContext.get('controllers.ProductController')
return the value cached at app-level? Maybe we need a new scope called CHILD_CONTEXT
?
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.
The CONTEXT
scope should be the default for controllers. It is designed to work nicely with your use case:
app.get('controllers.ProductController')
--> create and cache an instance atapp
levelrequestContext.get('controllers.ProductController')
--> Now it's the req context and a new instance will be created and cached atrequestContext
levelrequestContext.get('controllers.ProductController')
--> Hits the cache, get the same instance as 2anotherRequestContext.get('controllers.ProductController')
--> Now it's another req context and a new instance will be created and cached atanotherRequestContext
level
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.
Cool. In that case, I am proposing to:
- Call
inScope
fromapp.controller()
to automatically register all controllers in CONTEXT scope. - Add a test to verify the behavior described in your comment. For example:
- Register a controller class that stores state in the instance.
- Call
app.get
, mutate the state stored in the controller instance. - Make an HTTP request (this will get controller instance from the request context), verify that the response contains original (not mutated) state.
@bajtos Thanks for the feedback. Yes, I agree with you it's better to clean up the design by answering the following questions:
|
@raymondfeng great questions! 👍 I'll need more time to think them through, please allow few more days until I come back again. |
48629be
to
f066509
Compare
At high-level, In that light, it makes a lot of sense to me to treat
As a LoopBack user building an application, I'd like my controller-based routes to use our IoC container to obtain an instance of the controller class. As a developer maintaining LoopBack modules, I'd like the Route implementation to be independent of Context (as you wrote), to keep our routing as much decoupled from IoC as possible.
I believe this should be possible. Let's assume a controller route accepts a factory function accepting a single argument
ctx => controllerInstance;
ctx => instantiateClass(controllerClass, ctx); This raises the question whether we want to allow
ctx => controllerFactory(ctx);
ctx => ctx.get(controllerBindingKey);
I don't have an answer for this yet, I think it depends on how we solve the previous problem of registering & obtaining the controller instance.
Ideally, I'd say controllers should not be required to be bound in the context. This will force us to keep our design clean and not coupled with our IoC container too much. OTOH, depending on how we want to share the controller instances by contexts, it may be more practical to require all controllers to be bound to the context. As I am thinking about this, I think it can be reasonably easy to achieve by modifying app.getSync('servers.RestServer').route('GET', '/products', {/*spec*/}, ProductController, 'findAll');
// under the hood:
app.controller(ProductController);
this.route(
new ControllerRoute('GET', '/products', {/*spec*/}, ProductController, 'findAll'),
); @raymondfeng what are your thoughts? |
There are two options to interpret
Option 1 gives us one more level of abstraction and treat Option 2 is easier to explain but it requires us to use the class name to find the corresponding controller (mostly from the context).
+1. I see
+1.
+1.
ctx => controllerInstance;
ctx => instantiateClass(controllerClass, ctx);
I'll try to avoid direct invocation of
ctx => controllerFactory(ctx);
ctx => ctx.get(controllerBindingKey);
CONTEXT scope makes sense to me.
I prefer to have controllers to be bound to the context to avoid knowledge leakage. All controllers should be managed artifacts by LoopBack context.
app.getSync('servers.RestServer').route('GET', '/products', {/*spec*/}, ProductController, 'findAll');
// under the hood:
app.controller(ProductController);
this.route(
new ControllerRoute('GET', '/products', {/*spec*/}, ProductController, 'findAll'),
); I'm fine with on-demand binding of controllers.
See my comments inline. |
I think we are pretty much in agreement then!
Can we perhaps unify those two approaches by treating Option2 as the default way how to obtain the address for Option1? Something along the following line:
Thoughts? |
app.getSync('servers.RestServer').route('GET', '/products', {/*spec*/}, ProductController, 'findAll');
// under the hood:
app.controller(ProductController);
this.route(
new ControllerRoute('GET', '/products', {/*spec*/}, ProductController, 'findAll'),
);
My only concern with this approach is an edge case when there are two different controller classes with the same name - we need to be prepared to handle this situation. An example to illustrate my point is below. It's modeled after CQRS pattern. I know it's a bit silly but but the code is short and easy to understand this way. // this would typically live in a package/extension
let status: string = 'ok';
function registerStatusQueries(server: RestServer) {
class StatusController {
async getStatus() { return status; }
}
server.route('GET', '/status', {/*spec*/}, StatusController, 'getStatus');
}
function registerStatusCommands(server: RestServer) {
class StatusController {
async updateStatus(newStatus: string) { status = newStatus; }
}
server.route('PUT', '/status', {/*spec*/}, StatusController, 'updateStatus');
}
// this lives inside the main application setup
registerStatusQueries(restServer);
registerStatusCommands(restServer); |
|
d580f20
to
6cfea88
Compare
@bajtos I have updated the PR to implement what we have agreed. PTAL. |
6916fcf
to
bff4a47
Compare
methodName = this.spec['x-operation-name']; | ||
} | ||
this._controllerCtor = controllerCtor; | ||
this._controllerName = controllerName || controllerCtor.name; |
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.
controllerName
is always set, see L371.
packages/rest/src/http-handler.ts
Outdated
registerController( | ||
controllerCtor: ControllerClass, | ||
spec: ControllerSpec, | ||
factory?: ControllerFactory, |
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.
This API is too loose to my taste. For example, it's possible to register a controller in such way that factory
is returning an instance of a different class than controllerCtor
:
registerController(
ProductController,
spec,
ctx => new CategoryController());
IIUC, the only reason you need a direct access to controllerCtor
is to obtain the controller name if the spec does not provide it. If that's correct, then I am proposing to modify this API as follows:
- Always require the controller factory.
- Replace required
controllerCtor
with an optional controller name argument.
registerController(
factory: ControllerFactory,
spec: ControllerSpec,
name?: string,
)
A similar change should be applied to ControllerRoute
class.
Thoughts?
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.
Controller constructor is still critical to look up corresponding metadata for some of the actions. That's why we bind it to controller.current.ctor
at the moment.
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.
Controller constructor is still critical to look up corresponding metadata for some of the actions.
Could you be more specific please and list the required metadata?
How do you envision to address the possible edge case issue I pointed above, where the factory may be returning an instance of a different controller class than the class used for registration?
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.
We typically decorate controller classes and their members to provider metadata for actions such as authenticate
. Being able to access the controller constructor gives us access to metadata associated with the resolved controller class.
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.
Did you see 2adcaff?
packages/rest/src/http-handler.ts
Outdated
spec: ControllerSpec, | ||
factory?: ControllerFactory, | ||
) { | ||
this._routes.registerController(controllerCtor, spec); |
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.
factory
argument is ignored, I think you need to pass it to this._routes.registerController
? Please add a test that's failing with the current implementation because of that bug, it will serve as a regression guard for the future.
35cd971
to
2adcaff
Compare
@bajtos PTAL |
2adcaff
to
fb62f4b
Compare
packages/rest/src/http-handler.ts
Outdated
registerController<T>( | ||
controllerCtor: ControllerClass<T>, | ||
spec: ControllerSpec, | ||
controllerFactory?: ControllerFactory<T>, |
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 find it a bit weird to have two controller-related arguments interleaved with other non-controller arguments.
I am proposing to reorder the arguments of this method as follows:
registerController<T>(
spec: ControllerSpec,
controllerCtor: ControllerClass<T>,
controllerFactory?: ControllerFactory<T>,
) {}
The same comment applies to other places where controllerFactory
argument as added to a function/method accepting controllerCtor
.
I would also like to make controllerFactory
as a required argument, and treat controllerCtor
more like a reference for obtaining controller metadata. This will open more possibilities for us, for example allow controllerFactory
to return a plain object (no classes involved) and then craft controllerCtor
in such way that allows the rest of the framework to fetch the required metadata.
If we agree on this second change, then I would swap the order of factory/ctor argument:
registerController<T>(
spec: ControllerSpec,
controllerFactory?: ControllerFactory<T>,
controllerCtor: ControllerClass<T>,
) {}
This will also allow us to preserve required methodName
while grouping controller-related args in route<T>()
API below.
route<T>(
verb: string,
path: string,
spec: OperationObject,
controllerFactory: ControllerFactory<T>,
controllerCtor: ControllerClass<T>,
methodName: string,
){}
Thoughts?
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 move controllerFactory
to be next to controllerCtor
.
In most cases, we have ControllerClass
and the controller factory is for the class. With the intention to customize by exception, I leave controllerFactory to be after controlCtor and optional in most cases.
): ControllerFactory<T> { | ||
if (typeof source === 'string') { | ||
return ctx => ctx.get<T>(source); | ||
} else if (typeof source === 'function') { |
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.
These function is doing sort of two things: provide a string-based and class-based factory. I'd like it more to extract these two factories into standalone functions and require users to pick the right factory function when creating a controller route (see my previous comment about a required controllerFactory
arg), instead of letting the framework to create the factory function automagically. When I write "users", it does not necessary mean framework users building applications. I can imagine the high-level application API providing the factory function for callers, but keeping our internal design cleaner by making the factory function required.
I would be ok with leaving this out of the scope of this pull request, if it was not affecting the public API in a backwards-incompatible way. If backwards compatibility is not a concern, then I am ok with landing the currently proposed API.
Thoughts?
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 split it into three specific functions
fb62f4b
to
3d4bf2c
Compare
@bajtos I have addressed your comments. PTAL. |
3d4bf2c
to
23db7f9
Compare
23db7f9
to
5782ab8
Compare
61eb6b3
to
a7d716c
Compare
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 am not able to fully comprehend all implications of the current design. I don't see any obvious problem, so let's move on and land this pull request. We can always fix any problems later, if/when they arise.
} | ||
|
||
this._controllerFactory = | ||
controllerFactory || createControllerFactoryForClass(controllerCtor); |
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.
This is the part I'd prefer to avoid - can we modify ControllerRoute
to require the factory argument, and let app.controller
and friends to supply the right value?
- move controllFactory param next to controllerCtor - split createControllerFactory to three functions
a7d716c
to
d48c062
Compare
The PR ensures the existing controller binding is used to resolve/invoke the controller method. It allows
scope
and configuration of a controller is honored. Please noteinstantiateClass
always creates a new instance.If a route is set up without binding the controller to a context (app), we now create a binding in the request context.
Checklist
npm test
passes on your machinepackages/cli
were updatedpackages/example-*
were updated