Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

feat(rest): introduce an interceptor-chain based sequence of actions for REST #2927

Closed
wants to merge 5 commits into from

Conversation

raymondfeng
Copy link
Contributor

@raymondfeng raymondfeng commented May 21, 2019

The PR adds another way to build a sequence of actions for REST request/response processing. It works in a similar way as global interceptors with RequestContext and next.

The PR manages to allow backward compatibility. Two flavors can both be used.

It depends on #2908.

Checklist

👉 Read and sign the CLA (Contributor License Agreement) 👈

  • npm test passes on your machine
  • New tests added or existing tests modified to cover all changes
  • Code conforms with the style guide
  • API Documentation in code was updated
  • Documentation in /docs/site was updated
  • Affected artifact templates in packages/cli were updated
  • Affected example projects in examples/* were updated

👉 Check out how to submit a PR 👈

@raymondfeng raymondfeng force-pushed the handler-chain-sequence branch 4 times, most recently from aa8da54 to 61f83fb Compare May 23, 2019 17:52
@raymondfeng raymondfeng marked this pull request as ready for review May 24, 2019 01:30
@raymondfeng raymondfeng requested a review from bajtos as a code owner May 24, 2019 01:30
@bajtos
Copy link
Member

bajtos commented May 24, 2019

@raymondfeng how can I review the proposed changes at high level?

@bajtos
Copy link
Member

bajtos commented May 30, 2019

When we decided to build LB4 from scratch, one of our primary design goals was to avoid the concept of a handler chain, because we have had a lot of bad experience with composition of Express middleware in LB1-LB3. The concept of a sequence of actions was meant to supersede the concept of a handler chain and provide better UX.

I feel that re-introduction of the concept of a handler chain requires a deeper discussion and careful consideration of the wider context.

I don't have time to review the proposed changes now, will try to find time in the next few weeks 🤞

@raymondfeng raymondfeng force-pushed the handler-chain-sequence branch 3 times, most recently from 787c7d5 to 168850d Compare May 31, 2019 21:17
@raymondfeng raymondfeng force-pushed the handler-chain-sequence branch from 168850d to d80ce10 Compare June 6, 2019 15:41
@raymondfeng raymondfeng force-pushed the handler-chain-sequence branch 11 times, most recently from 2c9922f to 94214fa Compare June 11, 2019 05:16
@bajtos
Copy link
Member

bajtos commented Jun 14, 2019

@raymondfeng this pull request is quite large (+1,517 −613 lines). Please add a high-level description of the proposed user experience, so that we can keep the initial rounds of review at design level and don't deal with implementation details yet.

@raymondfeng raymondfeng force-pushed the handler-chain-sequence branch 2 times, most recently from f057f0e to f2759a3 Compare June 19, 2019 16:18
@raymondfeng raymondfeng changed the title feat(rest): introduce a handler-chain based sequence of actions for REST feat(rest): introduce an interceptor-chain based sequence of actions for REST Jun 19, 2019
@raymondfeng raymondfeng force-pushed the handler-chain-sequence branch 2 times, most recently from f2e586f to 8e0b9d1 Compare July 15, 2019 17:05
@raymondfeng raymondfeng force-pushed the handler-chain-sequence branch 4 times, most recently from fe9c9da to 56e8801 Compare July 24, 2019 19:58
@raymondfeng raymondfeng force-pushed the handler-chain-sequence branch 2 times, most recently from 2e813a6 to 0141607 Compare July 25, 2019 21:35
Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

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

I don't understand what problems are you trying to solve by the new design. My impression is that you are adding a lot of additional complexity, both in our implementation and for LB4 users, and I am not convinced that the benefits outweigh the downsides.

Let's discuss.

.bind(RestBindings.INVOKE_METHOD_ACTION)
.toClass(InvokeMethodAction)
.tag(RestTags.ACTION);
```
Copy link
Member

Choose a reason for hiding this comment

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

Please explain how to define the order in which individual REST actions are invoked. Is this order defined by the order in which the actions are bound? That does not seem very practical to me - it's one of the pain points of Express/Koa middleware that we want to avoid in LB4.


// TODO(bajtos) Make this mapping configurable at RestServer level,
// allow apps and extensions to contribute additional mappings.
const codeToStatusCodeMap: {[key: string]: number} = {
ENTITY_NOT_FOUND: 404,
};

export class RejectProvider implements Provider<Reject> {
@restAction('reject')
export class RejectAction extends BaseRestAction {
Copy link
Member

Choose a reason for hiding this comment

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

What are the implications for existing code inheriting from RejectProvider? This looks like a breaking change to me.

'send',
'route',
'parseParams',
'invoke',
Copy link
Member

Choose a reason for hiding this comment

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

How can LB4 applications customize the order of the actions?

Let's say I am an app developer and want to call a new action in my sequence, e.g. authenticate. What are the instructions?

@@ -41,14 +39,15 @@ import {DefaultSequence} from './sequence';
export class RestComponent implements Component {
providers: ProviderMap = {
[RestBindings.SequenceActions.LOG_ERROR.key]: LogErrorProvider,
[RestBindings.SequenceActions.FIND_ROUTE.key]: FindRouteProvider,
Copy link
Member

Choose a reason for hiding this comment

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

What is the implication of this change on existing code that's using RestBindings.SequenceActions.FIND_ROUTE?

(The same comment applies to other binding keys that you are renaming in this PR.)

// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.requestHandler(ctx.request, ctx.response, (err?: any) => {
if (err) reject(err);
else resolve(next());
Copy link
Member

Choose a reason for hiding this comment

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

IIUC, when the middleware handler function accepts next callback but does not call it, then the interceptor does not call next either. I find that problematic, because it breaks the assumptions that interceptors can rely on await next() mechanism to run code before and after request processing.

Example middleware:

function static(req, res, next) {
  const asset = path.join(assetsRoot, req.path);
  if (fs.existsSync()) {
    res.sendFile(assert)
  } else {
    next();
  }
}
// or also
const static = new MiddlewareAction(require('serve-static'), assetsRoot);

Example interceptor (a sequence action):

async function logRequest(ctx, next) {
  const start = process.hrtime();
  await next();
  const delta = process.hrtime(start);
  console.log(
    '%s %s %s %s ms',
    ctx.req.method, 
    ctx.req.path, 
    ctx.res.statusCode, 
    // time in milliseconds
    Math.round(delta[0]*1e3 + delta[1]/1e6) 
  );
}

In this example, no log messages are printed for requests served as static assets.

if (this.requestHandler.length < 3) {
// The express middleware does not call `next`
this.requestHandler(ctx.request, ctx.response, () => {});
resolve(next());
Copy link
Member

Choose a reason for hiding this comment

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

This is problematic. Consider the following example.

// middleware
function myRoute(req, res) {
  doSomeAsyncWork((err, result) => {
    console.log('async work is done');
    res.json(result);
  });
}

// interceptor/sequence action
async function logRequest(ctx, next) {
  console.log(
    'START %s %s',
    ctx.req.method, 
    ctx.req.path, 
  );

  await next();

  console.log(
    'DONE %s %s %s',
    ctx.req.method, 
    ctx.req.path, 
    ctx.res.statusCode, 
  );
}

This will produce the following console logs:

START GET /api/users
DONE GET /api/users
async work is done

Expected output:

START GET /api/users
async work is done
DONE GET /api/users

IMO, when the middleware does not call next, you should use on-finished module to wait for the response to be written before you call next.

See the existing implementation here:

https://github.com/strongloop/loopback-next/blob/0b5a47227ac0ab71bf67cd8836dfe3ecbbe9a17d/packages/rest/src/router/external-express-routes.ts#L160-L177

@bajtos
Copy link
Member

bajtos commented Jul 29, 2019

The application code needs to be fully aware of all elements for a sequence and how they are composed using the waterfall style.

IMO, this problem is present in your proposal too. The application still needs to be aware of all elements to ensure they are invoked in the correct order.

The application code needs to understand the signature and responsibility of each function representing an action.

This is intentional! In the current design, it's explicitly clear what is the sequence action expecting to receive from the previous sequence steps and what it contributes back.

When authenticate action requires the resolved route to be available, then we can verify at the compile time that it is called only after findRoute has been called.

In your design, we have a single god-like object context and the compiler cannot verify that CURRENT_ROUTE was bound to the context before authenticate action was invoked.

It's cumbersome to add new actions, especially for existing applications as code change is required.

I am afraid your proposal does not show how it allows actions to be added to existing applications with no code change. Please show us!

Isn't such behavior dangerous though? Let's say if components have a mechanism allowing them to contribute new actions to the sequence automatically, then app developers can be caught by a surprise that a new version of a component they are using in their project is suddenly adding a new action to the sequence, especially if the application does not want to execute such sequence!

If actions can be added to the sequence with no code change, then please update your proposal to show how application can prevent such action to be added automatically.

It's not easy to monitor or trap actions within a sequence.

Please explain this in more details. The sequence action is a regular JavaScript/TypeScript function, users are free to use any monitoring/trapping solution as they like.

It's not easy to reuse Express middleware as actions.

This is very simple to address in the current framework. We just need a helper to invoke a middleware as a sequence action.

We can leverage the existing implementation: https://github.com/strongloop/loopback-next/blob/0b5a47227ac0ab71bf67cd8836dfe3ecbbe9a17d/packages/rest/src/router/external-express-routes.ts#L160-L177

Usage (assuming we extract executeRequestHandler into a standalone function):

const corsHandler = cors(/*cors options*/):

export class MySequence implements SequenceHandler {
  async handle(context: RequestContext) {
    try {
      const {request, response} = context
      if (await executeRequestHandler(corsHandler, request, response))) return;
      const route = this.findRoute(request);
      // etc.
    } catch (error) {
      this.reject(context, error);
    }
  }
}

Circular dependencies cause undefined exported classes
- add utilities to wrap express middleware into actions
- introduce base action class
- inject request context instead of the current one
- use method DI for actions
@raymondfeng raymondfeng force-pushed the handler-chain-sequence branch from 0141607 to 6a7037c Compare July 29, 2019 16:35
@raymondfeng
Copy link
Contributor Author

@bajtos Thank you for the feedback. Let me clarify a bit:

  1. Both approaches have pros and cons. This PR adds a different style and keep the possibility to use one of them.

  2. The key difference is functional composition with parameter passing vs. command composition with context sharing. The first style grants full control and full responsibility for application developers while the second one allows better extensibility and divide of responsibility between application and framework/extension developers.

  3. Prior art for the second style is Koa middleware. It's also consistent with our interceptor design.

I'll go into more details later.

@raymondfeng
Copy link
Contributor Author

Closing it to favor #5366

@raymondfeng raymondfeng closed this Jun 1, 2020
# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants