Skip to content
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

[kfetch] Add support for interceptors #22128

Merged
merged 26 commits into from
Aug 31, 2018

Conversation

sorenlouv
Copy link
Member

@sorenlouv sorenlouv commented Aug 17, 2018

Closes #19060
Closes #21982

This PR adds support for http interceptors similar to angular http interceptors.

This makes it possible for plugins to transform request config and response of http requests made with kfetch. This is useful for adding additional headers.

To add an interceptor:

import { addInterceptor } from 'ui/kfetch';

addInterceptor({
  request: async function(config) {
    return config;
  },

  // catch error and return new config object
  requestError: async function(e) {
    const config = {};
    return config;
  },

  response: async function(response) {
    return response;
  },

  // catch error and return new response object
  responseError: async function(e) {
    const response = {};
    return response;
  }
});

Behaviour
Request hooks start from the newest interceptor and end with the oldest, and the response hooks are the opposite. That means that if you add an interceptor before any other code runs your interceptor will always be the last to run before sending the request and the first to run when the response comes back. Each additional interceptor adds an additional "layer" to the wrapper around the request, it's the new first and last interceptor, which makes a lot of sense to me.

  • if a request hook from one interceptor throws/rejects another interceptor will get the error in their requestError hook and can resolve to allow the request to continue
  • if a response hook from one interceptor throws/rejects it will call the responseError hook from the next interceptor.
  • If all interceptors reject in their request* hooks the request is never sent to the server but the responseError hooks are still called so one of the responseError hooks could still cause the request to ultimately resolve.

@elasticmachine
Copy link
Contributor

💔 Build Failed

@sorenlouv
Copy link
Member Author

sorenlouv commented Aug 17, 2018

@spalger I need some of your typescript foo:

error TS2345: Argument of type '(acc: Promise<never>, interceptor: Interceptor) => Promise<any>' is not assignable to parameter of type '(previousValue: Promise<any>, currentValue: Interceptor, currentIndex: number, array: Interceptor...'.

Types of parameters 'acc' and 'previousValue' are incompatible.
Type 'Promise<any>' is not assignable to type 'Promise<never>'.
Type 'any' is not assignable to type 'never'.
   
      return interceptors.reduce((acc, interceptor) => {
                                   ~~~~~~~~~~~~~~~~~~~~~~~

in https://github.com/elastic/kibana/blob/09983592029ec06b9e6cc90550df4ae137de341c/src/ui/public/kfetch/kfetch.ts#L88-L97

@elasticmachine
Copy link
Contributor

💔 Build Failed

@cjcenizal
Copy link
Contributor

@epixa @sqren Just so I'm clear on this, we intend to update all places where we use $http.interceptors to use kfetch.interceptors in tandem, right?

E.g. we will update xsrf.js like this (and we'll do the same elsewhere):

import $ from 'jquery';
import { set } from 'lodash';
import { interceptors } from 'ui/kfetch';

const xsrfRequestInterceptor = {
  request: function (opts) {
    const { kbnXsrfToken = true } = opts;
    if (kbnXsrfToken) {
      set(opts, ['headers', 'kbn-version'], internals.version);
    }
    return opts;
  }
};

export function initChromeXsrfApi(chrome, internals) {

  chrome.getXsrfToken = function () {
    return internals.version;
  };

  $.ajaxPrefilter(function ({ kbnXsrfToken = true }, originalOptions, jqXHR) {
    if (kbnXsrfToken) {
      jqXHR.setRequestHeader('kbn-version', internals.version);
    }
  });

  chrome.$setupXsrfRequestInterceptor = function ($httpProvider) {
    // Ensure all requests go through the interceptor, regardless of which fetch module is used.
    $httpProvider.interceptors.push(xsrfRequestInterceptor);
    interceptors.push(xsrfRequestInterceptor);
  };
}

@cjcenizal cjcenizal added Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc v7.0.0 v6.5.0 labels Aug 17, 2018
Copy link
Contributor

@cjcenizal cjcenizal left a comment

Choose a reason for hiding this comment

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

I think we still need to export interceptors from index.ts, right?

export { kfetch, interceptors } from './kfetch';
export { kfetchAbortable } from './kfetch_abortable';

Though personally, I think I'd lean towards just exposing an "add" method, to avoid exposing the data structure:

export { kfetch, addInterceptor } from './kfetch';
export { kfetchAbortable } from './kfetch_abortable';

@nreese
Copy link
Contributor

nreese commented Aug 17, 2018

Good timing on this PR. @elastic/kibana-sharing is in the process of moving the reporting top nav menus to EUI and react. I would like to migrate all of the reporting API calls from $http to kfetch. Will this PR add intercepters for checkXPackInfoChange?

I have a WIP PR that is trying to update the xpackInfo into vanilla javascript
#22103

Copy link
Contributor

@cjcenizal cjcenizal left a comment

Choose a reason for hiding this comment

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

🔥 This code looks AWESOME. Just had a few suggestions and requests, mostly around testing.

* under the License.
*/

export class FetchError extends Error {
Copy link
Contributor

@cjcenizal cjcenizal Aug 17, 2018

Choose a reason for hiding this comment

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

I know this is a tiny bit outside of scope but can we rename this KFetchError? This way it will be really obvious which module it has originated from. I think FetchError is just a bit too vague. I mention this because I've introduced a SearchError in the rollups feature branch and seeing this error makes me think that the SearchError name is too vague too. I'll probably rename it CourierSearchError or something.

Doing a rename will require making a change in one other place in the codebase: https://github.com/elastic/kibana/blob/master/src/ui/public/error_auto_create_index/error_auto_create_index.test.js

Copy link
Member Author

Choose a reason for hiding this comment

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

Sure, will change.

addedByInterceptor: true,
foo: 'bar',
});
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we add a test verifying that a Promise is a valid return value?

    it('response: should modify response via Promise interceptor', async () => {
      fetchMock.get(matcherName, new Response(JSON.stringify({ foo: 'bar' })));
      interceptors.push({
        response: res => {
          return new Promise((resolve) => {
            resolve({
              ...res,
              addedByInterceptor: true,
            });
          }
        },
      });

      const resp = await kfetch({ pathname: 'my/path' });
      expect(resp).toEqual({
        addedByInterceptor: true,
        foo: 'bar',
      });
    });

Copy link
Contributor

Choose a reason for hiding this comment

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

This also begs the question, what's the expected behavior if an interceptor returns a rejected promise?

'kbn-version': 'my-version',
},
});
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we also verify a promise return val here?

    it('request: should add headers via Promise interceptor', async () => {
      fetchMock.get(matcherName, new Response(JSON.stringify({ foo: 'bar' })));
      interceptors.push({
        request: config => {
          return new Promise(resolve => {
            resolve({
              ...config,
              headers: {
                ...config.headers,
                addedByInterceptor: true,
              },
            });
          });
        },
      });

      await kfetch({
        pathname: 'my/path',
        headers: { myHeader: 'foo' },
      });

      expect(fetchMock.lastOptions(matcherName)).toEqual({
        method: 'GET',
        credentials: 'same-origin',
        headers: {
          addedByInterceptor: true,
          myHeader: 'foo',
          'Content-Type': 'application/json',
          'kbn-version': 'my-version',
        },
      });
    });

});

expect(resp).rejects.toThrow('my custom error');
});
Copy link
Contributor

Choose a reason for hiding this comment

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

And here.

    it('responseError: should throw custom error when a rejected Promise is returned', () => {
      fetchMock.get(matcherName, {
        status: 404,
      });

      interceptors.push({
        responseError: e => {
          return new Promise((resolve, reject) => {
            reject(new Error('my custom error'));
          })
        },
      });

      const resp = kfetch({
        pathname: 'my/path',
      });

      expect(resp).rejects.toThrow('my custom error');
    });


expect(resp).resolves.toBe('resolved valued');
});
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we add some tests for requestError?

import { metadata } from 'ui/metadata';
import { Interceptor } from './kfetch';

export const defaultInterceptor: Interceptor = {
Copy link
Contributor

@cjcenizal cjcenizal Aug 17, 2018

Choose a reason for hiding this comment

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

From a "core principles" standpoint, I like the dogfooding and consistency aspect of creating a default interceptor to apply these defaults, but at the same this adds indirection to the code. In the end this indirection hurts more than it helps, in my opinion. I'll leave it up to you but personally I think I would find the code easier to follow if we assigned defaults within kfetch, as we were doing originally.

Copy link
Member Author

Choose a reason for hiding this comment

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

I agree this is overabstraction. Will change.

@cjcenizal
Copy link
Contributor

@nreese I'm assuming that as a second part of this PR, we should update all the places where we consume $httpProvider.interceptors to add the same interceptors for kfetch and then test thoroughly to make sure they all work as expected:

@epixa @sqren Sound good?

@sorenlouv
Copy link
Member Author

Though personally, I think I'd lean towards just exposing an "add" method, to avoid exposing the data structure
@cjcenizal

I'm okay with doing that. Reason I exposed access to the data structure is because I don't know the exact use-cases, and then I lean towards more flexibility. Eg. will plugin authors want to delete or re-order interceptors? If not, add method should suffice.

@elasticmachine
Copy link
Contributor

💔 Build Failed

@sorenlouv
Copy link
Member Author

retest

@elasticmachine
Copy link
Contributor

💔 Build Failed

@cjcenizal
Copy link
Contributor

I'm okay with doing that. Reason I exposed access to the data structure is because I don't know the exact use-cases, and then I lean towards more flexibility. Eg. will plugin authors want to delete or re-order interceptors?

If you're on the fence, I think I'd lean towards YAGNI here, and wait for the need to delete/re-order to arise before adding that to the interface. But I don't feel very strongly about this, so I'm totally OK leaving it as-is if you have a strong hunch it will be useful. 😅

@sorenlouv
Copy link
Member Author

sorenlouv commented Aug 18, 2018

I'm assuming that as a second part of this PR, we should update all the places where we consume $httpProvider.interceptors to add the same interceptors for kfetch and then test thoroughly to make sure they all work as expected:

xsrf.js
on_session_timeout.js
on_unauthorized_response.js
check_xpack_info_change.js
@cjcenizal

Yeah, that's probably a good idea. Do you think we should keep them as interceptors, or move them into kfetch core? I agree with your earlier comment that interceptors causes additional indirection, and perhaps we should use interceptors as an escape hatch mostly aimed at 3rd parties who can't just modify the core. What do you think?

The interceptor in xsrf.js can probably be removed. kbn-version header is already added in kfetch core.

@sorenlouv sorenlouv changed the title [kfetch] Add support for interceptors (WIP) [kfetch] Add support for interceptors Aug 18, 2018
@sorenlouv sorenlouv force-pushed the add-kfetch-interceptor branch 2 times, most recently from b99783a to 5a81a38 Compare August 18, 2018 23:09
@trevan
Copy link
Contributor

trevan commented Aug 18, 2018

@sqren, my opinion is since those other files are in x-pack and you can run Kibana without them using the OSS version, they should be interceptors so that they don't cause problems for the OSS version.

@elasticmachine
Copy link
Contributor

💔 Build Failed

@cjcenizal
Copy link
Contributor

I agree with @trevan. I think we should also try to avoid making any more changes than we have to, to reduce risk of introducing bugs.

Longer term I think it could make sense to look at what we’re using the interceptors for and consider another mechanism. I’m sure the platform team will have thoughts on that!

@elasticmachine
Copy link
Contributor

💔 Build Failed

@sorenlouv
Copy link
Member Author

retest

@sorenlouv
Copy link
Member Author

retest

@elasticmachine
Copy link
Contributor

💚 Build Succeeded

@sorenlouv
Copy link
Member Author

Yay! CI passing.

@nreese
Copy link
Contributor

nreese commented Aug 30, 2018

Does anyone know if reporting is using kfetch?

It is all $http at the moment but will be migrated to kfetch in the near future

@sorenlouv
Copy link
Member Author

sorenlouv commented Aug 30, 2018

It is all $http at the moment but will be migrated to kfetch in the near future

Odd. The tests have been failing consistently until I made the last commit.

@elasticmachine
Copy link
Contributor

💚 Build Succeeded

@elasticmachine
Copy link
Contributor

💔 Build Failed

@spalger
Copy link
Contributor

spalger commented Aug 31, 2018

retest

@elasticmachine
Copy link
Contributor

💚 Build Succeeded

Copy link
Contributor

@spalger spalger left a comment

Choose a reason for hiding this comment

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

LGTM, would you mind updating the PR description before merging?

@cjcenizal
Copy link
Contributor

@spalger What do you think of holding off on merging until #22128 (comment) has been addressed? I hate to put off merging this PR any longer than necessary, but it seems important we ensure that all existing interceptors are integrated into kfetch before merging, because it seems likely that people will migrate from $http to kfetch under this assumption.

@sorenlouv
Copy link
Member Author

sorenlouv commented Aug 31, 2018

Personally I think that migrating $http interceptors is a separate task. Merging this in will fix #21982, unblock @trevan and allow anybody to migrate $http interceptors as they like. I don't want to delay that too much.

I'll gladly help migrating the current $http interceptors but since I don't know their domain, I don't want to lead that effort.

Eg. just like the initial PR for kfetch didn't migrate anything. Instead subsequent PRs have started to make use of it.

@cjcenizal
Copy link
Contributor

Good point @sqren. And I suppose people today are already assuming that kfetch behaves identically to $http, so blocking this PR from being merged wouldn't solve that problem. I'll create a separate issue to track the interceptor migration.

@sorenlouv
Copy link
Member Author

Thanks @cjcenizal! Merging now, then.

@sorenlouv sorenlouv merged commit 1daa265 into elastic:master Aug 31, 2018
@sorenlouv sorenlouv deleted the add-kfetch-interceptor branch August 31, 2018 13:17
@sorenlouv
Copy link
Member Author

Btw. @spalger I updated the description so usage is now correct, and added your notes about the behaviour. Ok?

@elasticmachine
Copy link
Contributor

💔 Build Failed

@cjcenizal
Copy link
Contributor

Created #22594 to track interceptor parity between kfetch and $http.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Team:Core Core services & architecture: plugins, logging, config, saved objects, http, ES client, i18n, etc v6.5.0 v7.0.0
Projects
None yet
8 participants