[New platform] Support configuring request specific basePath (#35951) (#36257)

* expose IncomingMessage from KibanaRequest

It's applied to identify incoming requests across New and Legacy platforms. We can rely on the value if want to attach additional information to incoming requests without mutating them.

* support attaching basePath information to incoming requests

*  Support Url changing for incoming requests

* add tests

* use NP API in the legacy platform

* relax KibanaRequest typings

* check basePath cannot be set twice

* address @eli comments

* generate docs
This commit is contained in:
Mikhail Shustov 2019-05-08 17:45:37 +02:00 committed by GitHub
parent b30723ab96
commit 8ca20f554b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 292 additions and 43 deletions

View file

@ -8,7 +8,7 @@
<b>Signature:</b>
```typescript
export declare class KibanaRequest<Params, Query, Body>
export declare class KibanaRequest<Params = unknown, Query = unknown, Body = unknown>
```
## Properties
@ -27,4 +27,5 @@ export declare class KibanaRequest<Params, Query, Body>
| --- | --- | --- |
| [from(req, routeSchemas)](./kibana-plugin-server.kibanarequest.from.md) | <code>static</code> | Factory for creating requests. Validates the request before creating an instance of a KibanaRequest. |
| [getFilteredHeaders(headersToKeep)](./kibana-plugin-server.kibanarequest.getfilteredheaders.md) | | |
| [unstable\_getIncomingMessage()](./kibana-plugin-server.kibanarequest.unstable_getincomingmessage.md) | | |

View file

@ -0,0 +1,15 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [KibanaRequest](./kibana-plugin-server.kibanarequest.md) &gt; [unstable\_getIncomingMessage](./kibana-plugin-server.kibanarequest.unstable_getincomingmessage.md)
## KibanaRequest.unstable\_getIncomingMessage() method
<b>Signature:</b>
```typescript
unstable_getIncomingMessage(): import("http").IncomingMessage;
```
<b>Returns:</b>
`import("http").IncomingMessage`

View file

@ -19,4 +19,5 @@ export interface OnRequestToolkit
| [next](./kibana-plugin-server.onrequesttoolkit.next.md) | <code>() =&gt; OnRequestResult</code> | To pass request to the next handler |
| [redirected](./kibana-plugin-server.onrequesttoolkit.redirected.md) | <code>(url: string) =&gt; OnRequestResult</code> | To interrupt request handling and redirect to a configured url |
| [rejected](./kibana-plugin-server.onrequesttoolkit.rejected.md) | <code>(error: Error, options?: {`<p/>` statusCode?: number;`<p/>` }) =&gt; OnRequestResult</code> | Fail the request with specified error. |
| [setUrl](./kibana-plugin-server.onrequesttoolkit.seturl.md) | <code>(newUrl: string &#124; Url) =&gt; void</code> | Change url for an incoming request. |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index) &gt; [kibana-plugin-server](./kibana-plugin-server.md) &gt; [OnRequestToolkit](./kibana-plugin-server.onrequesttoolkit.md) &gt; [setUrl](./kibana-plugin-server.onrequesttoolkit.seturl.md)
## OnRequestToolkit.setUrl property
Change url for an incoming request.
<b>Signature:</b>
```typescript
setUrl: (newUrl: string | Url) => void;
```

View file

@ -10,5 +10,7 @@
http: {
registerAuth: HttpServiceSetup['registerAuth'];
registerOnRequest: HttpServiceSetup['registerOnRequest'];
getBasePathFor: HttpServiceSetup['getBasePathFor'];
setBasePathFor: HttpServiceSetup['setBasePathFor'];
};
```

View file

@ -17,5 +17,5 @@ export interface PluginSetupContext
| Property | Type | Description |
| --- | --- | --- |
| [elasticsearch](./kibana-plugin-server.pluginsetupcontext.elasticsearch.md) | <code>{`<p/>` adminClient$: Observable&lt;ClusterClient&gt;;`<p/>` dataClient$: Observable&lt;ClusterClient&gt;;`<p/>` }</code> | |
| [http](./kibana-plugin-server.pluginsetupcontext.http.md) | <code>{`<p/>` registerAuth: HttpServiceSetup['registerAuth'];`<p/>` registerOnRequest: HttpServiceSetup['registerOnRequest'];`<p/>` }</code> | |
| [http](./kibana-plugin-server.pluginsetupcontext.http.md) | <code>{`<p/>` registerAuth: HttpServiceSetup['registerAuth'];`<p/>` registerOnRequest: HttpServiceSetup['registerOnRequest'];`<p/>` getBasePathFor: HttpServiceSetup['getBasePathFor'];`<p/>` setBasePathFor: HttpServiceSetup['setBasePathFor'];`<p/>` }</code> | |

View file

@ -30,6 +30,7 @@ import { ByteSizeValue } from '@kbn/config-schema';
import { HttpConfig, Router } from '.';
import { loggingServiceMock } from '../logging/logging_service.mock';
import { HttpServer } from './http_server';
import { KibanaRequest } from './router';
const chance = new Chance();
@ -613,3 +614,97 @@ test('throws an error if starts without set up', async () => {
`"Http server is not setup up yet"`
);
});
test('#getBasePathFor() returns base path associated with an incoming request', async () => {
const {
getBasePathFor,
setBasePathFor,
registerRouter,
server: innerServer,
registerOnRequest,
} = await server.setup(config);
const path = '/base-path';
registerOnRequest((req, t) => {
setBasePathFor(req, path);
return t.next();
});
const router = new Router('/');
router.get({ path: '/', validate: false }, (req, res) => res.ok({ key: getBasePathFor(req) }));
registerRouter(router);
await server.start(config);
await supertest(innerServer.listener)
.get('/')
.expect(200)
.then(res => {
expect(res.body).toEqual({ key: path });
});
});
test('#getBasePathFor() is based on server base path', async () => {
const configWithBasePath = {
...config,
basePath: '/bar',
};
const {
getBasePathFor,
setBasePathFor,
registerRouter,
server: innerServer,
registerOnRequest,
} = await server.setup(configWithBasePath);
const path = '/base-path';
registerOnRequest((req, t) => {
setBasePathFor(req, path);
return t.next();
});
const router = new Router('/');
router.get({ path: '/', validate: false }, async (req, res) =>
res.ok({ key: getBasePathFor(req) })
);
registerRouter(router);
await server.start(configWithBasePath);
await supertest(innerServer.listener)
.get('/')
.expect(200)
.then(res => {
expect(res.body).toEqual({ key: `${configWithBasePath.basePath}${path}` });
});
});
test('#setBasePathFor() cannot be set twice for one request', async () => {
const incomingMessage = {
url: '/',
};
const kibanaRequestFactory = {
from() {
return KibanaRequest.from(
{
headers: {},
path: '/',
raw: {
req: incomingMessage,
},
} as any,
undefined
);
},
};
jest.doMock('./router/request', () => ({
KibanaRequest: jest.fn(() => kibanaRequestFactory),
}));
const { setBasePathFor } = await server.setup(config);
const setPath = () => setBasePathFor(kibanaRequestFactory.from(), '/path');
setPath();
expect(setPath).toThrowErrorMatchingInlineSnapshot(
`"Request basePath was previously set. Setting multiple times is not supported."`
);
});

View file

@ -17,7 +17,7 @@
* under the License.
*/
import { Server, ServerOptions } from 'hapi';
import { Request, Server, ServerOptions } from 'hapi';
import { modifyUrl } from '../../utils';
import { Logger } from '../logging';
@ -25,8 +25,7 @@ import { HttpConfig } from './http_config';
import { createServer, getServerOptions } from './http_tools';
import { adoptToHapiAuthFormat, AuthenticationHandler } from './lifecycle/auth';
import { adoptToHapiOnRequestFormat, OnRequestHandler } from './lifecycle/on_request';
import { Router } from './router';
import { Router, KibanaRequest } from './router';
import {
SessionStorageCookieOptions,
createCookieSessionStorageFactory,
@ -50,12 +49,18 @@ export interface HttpServerSetup {
* Can register any number of OnRequestHandlers, which are called in sequence (from the first registered to the last)
*/
registerOnRequest: (requestHandler: OnRequestHandler) => void;
getBasePathFor: (request: KibanaRequest | Request) => string;
setBasePathFor: (request: KibanaRequest | Request, basePath: string) => void;
}
export class HttpServer {
private server?: Server;
private registeredRouters = new Set<Router>();
private authRegistered = false;
private basePathCache = new WeakMap<
ReturnType<KibanaRequest['unstable_getIncomingMessage']>,
string
>();
constructor(private readonly log: Logger) {}
@ -72,6 +77,28 @@ export class HttpServer {
this.registeredRouters.add(router);
}
// passing hapi Request works for BWC. can be deleted once we remove legacy server.
private getBasePathFor(config: HttpConfig, request: KibanaRequest | Request) {
const incomingMessage =
request instanceof KibanaRequest ? request.unstable_getIncomingMessage() : request.raw.req;
const requestScopePath = this.basePathCache.get(incomingMessage) || '';
const serverBasePath = config.basePath || '';
return `${serverBasePath}${requestScopePath}`;
}
// should work only for KibanaRequest as soon as spaces migrate to NP
private setBasePathFor(request: KibanaRequest | Request, basePath: string) {
const incomingMessage =
request instanceof KibanaRequest ? request.unstable_getIncomingMessage() : request.raw.req;
if (this.basePathCache.has(incomingMessage)) {
throw new Error(
'Request basePath was previously set. Setting multiple times is not supported.'
);
}
this.basePathCache.set(incomingMessage, basePath);
}
public setup(config: HttpConfig): HttpServerSetup {
const serverOptions = getServerOptions(config);
this.server = createServer(serverOptions);
@ -84,6 +111,8 @@ export class HttpServer {
fn: AuthenticationHandler<T>,
cookieOptions: SessionStorageCookieOptions<T>
) => this.registerAuth(fn, cookieOptions, config.basePath),
getBasePathFor: this.getBasePathFor.bind(this, config),
setBasePathFor: this.setBasePathFor.bind(this),
// Return server instance with the connection options so that we can properly
// bridge core and the "legacy" Kibana internally. Once this bridge isn't
// needed anymore we shouldn't return the instance from this method.

View file

@ -26,6 +26,8 @@ const createSetupContractMock = () => {
registerAuth: jest.fn(),
registerOnRequest: jest.fn(),
registerRouter: jest.fn(),
getBasePathFor: jest.fn(),
setBasePathFor: jest.fn(),
// we can mock some hapi server method when we need it
server: {} as Server,
};

View file

@ -18,6 +18,8 @@
*/
import path from 'path';
import { parse } from 'url';
import request from 'request';
import * as kbnTestServer from '../../../../test_utils/kbn_server';
import { Router } from '../router';
@ -27,12 +29,12 @@ import { url as onReqUrl } from './__fixtures__/plugins/dummy_on_request/server/
describe('http service', () => {
describe('setup contract', () => {
describe('#registerAuth()', () => {
const dummySecurityPlugin = path.resolve(__dirname, './__fixtures__/plugins/dummy_security');
const plugin = path.resolve(__dirname, './__fixtures__/plugins/dummy_security');
let root: ReturnType<typeof kbnTestServer.createRoot>;
beforeAll(async () => {
root = kbnTestServer.createRoot(
{
plugins: { paths: [dummySecurityPlugin] },
plugins: { paths: [plugin] },
},
{
dev: true,
@ -109,15 +111,12 @@ describe('http service', () => {
});
describe('#registerOnRequest()', () => {
const dummyOnRequestPlugin = path.resolve(
__dirname,
'./__fixtures__/plugins/dummy_on_request'
);
const plugin = path.resolve(__dirname, './__fixtures__/plugins/dummy_on_request');
let root: ReturnType<typeof kbnTestServer.createRoot>;
beforeAll(async () => {
beforeEach(async () => {
root = kbnTestServer.createRoot(
{
plugins: { paths: [dummyOnRequestPlugin] },
plugins: { paths: [plugin] },
},
{
dev: true,
@ -136,7 +135,7 @@ describe('http service', () => {
await root.start();
}, 30000);
afterAll(async () => await root.shutdown());
afterEach(async () => await root.shutdown());
it('Should support passing request through to the route handler', async () => {
await kbnTestServer.request.get(root, onReqUrl.root).expect(200, { content: 'ok' });
});
@ -160,5 +159,79 @@ describe('http service', () => {
await kbnTestServer.request.get(root, onReqUrl.independentReq).expect(200);
});
});
describe('#registerOnRequest() toolkit', () => {
let root: ReturnType<typeof kbnTestServer.createRoot>;
beforeEach(async () => {
root = kbnTestServer.createRoot();
}, 30000);
afterEach(async () => await root.shutdown());
it('supports Url change on the flight', async () => {
const { http } = await root.setup();
http.registerOnRequest((req, t) => {
t.setUrl(parse('/new-url'));
return t.next();
});
const router = new Router('/');
router.get({ path: '/new-url', validate: false }, async (req, res) =>
res.ok({ key: 'new-url-reached' })
);
http.registerRouter(router);
await root.start();
await kbnTestServer.request.get(root, '/').expect(200, { key: 'new-url-reached' });
});
it('url re-write works for legacy server as well', async () => {
const { http } = await root.setup();
const newUrl = '/new-url';
http.registerOnRequest((req, t) => {
t.setUrl(newUrl);
return t.next();
});
await root.start();
const kbnServer = kbnTestServer.getKbnServer(root);
kbnServer.server.route({
method: 'GET',
path: newUrl,
handler: () => 'ok-from-legacy',
});
await kbnTestServer.request.get(root, '/').expect(200, 'ok-from-legacy');
});
});
describe('#getBasePathFor()/#setBasePathFor()', () => {
let root: ReturnType<typeof kbnTestServer.createRoot>;
beforeEach(async () => {
root = kbnTestServer.createRoot();
}, 30000);
afterEach(async () => await root.shutdown());
it('basePath information for an incoming request is available in legacy server', async () => {
const reqBasePath = '/requests-specific-base-path';
const { http } = await root.setup();
http.registerOnRequest((req, t) => {
http.setBasePathFor(req, reqBasePath);
return t.next();
});
await root.start();
const legacyUrl = '/legacy';
const kbnServer = kbnTestServer.getKbnServer(root);
kbnServer.server.route({
method: 'GET',
path: legacyUrl,
handler: kbnServer.newPlatform.setup.core.http.getBasePathFor,
});
await kbnTestServer.request.get(root, legacyUrl).expect(200, reqBasePath);
});
});
});
});

View file

@ -17,6 +17,7 @@
* under the License.
*/
import { Url } from 'url';
import Boom from 'boom';
import { Lifecycle, Request, ResponseToolkit } from 'hapi';
import { KibanaRequest } from '../router';
@ -64,14 +65,10 @@ export interface OnRequestToolkit {
redirected: (url: string) => OnRequestResult;
/** Fail the request with specified error. */
rejected: (error: Error, options?: { statusCode?: number }) => OnRequestResult;
/** Change url for an incoming request. */
setUrl: (newUrl: string | Url) => void;
}
const toolkit: OnRequestToolkit = {
next: OnRequestResult.next,
redirected: OnRequestResult.redirected,
rejected: OnRequestResult.rejected,
};
/** @public */
export type OnRequestHandler<Params = any, Query = any, Body = any> = (
req: KibanaRequest<Params, Query, Body>,
@ -86,11 +83,20 @@ export type OnRequestHandler<Params = any, Query = any, Body = any> = (
*/
export function adoptToHapiOnRequestFormat(fn: OnRequestHandler) {
return async function interceptRequest(
req: Request,
request: Request,
h: ResponseToolkit
): Promise<Lifecycle.ReturnValue> {
try {
const result = await fn(KibanaRequest.from(req, undefined), toolkit);
const result = await fn(KibanaRequest.from(request, undefined), {
next: OnRequestResult.next,
redirected: OnRequestResult.redirected,
rejected: OnRequestResult.rejected,
setUrl: (newUrl: string | Url) => {
request.setUrl(newUrl);
// We should update raw request as well since it can be proxied to the old platform
request.raw.req.url = typeof newUrl === 'string' ? newUrl : newUrl.href;
},
});
if (OnRequestResult.isValidResult(result)) {
if (result.isNext()) {
return h.continue;

View file

@ -24,7 +24,7 @@ import { filterHeaders, Headers } from './headers';
import { RouteSchemas } from './route';
/** @public */
export class KibanaRequest<Params, Query, Body> {
export class KibanaRequest<Params = unknown, Query = unknown, Body = unknown> {
/**
* Factory for creating requests. Validates the request before creating an
* instance of a KibanaRequest.
@ -71,12 +71,22 @@ export class KibanaRequest<Params, Query, Body> {
public readonly headers: Headers;
public readonly path: string;
constructor(req: Request, readonly params: Params, readonly query: Query, readonly body: Body) {
this.headers = req.headers;
this.path = req.path;
constructor(
private readonly request: Request,
readonly params: Params,
readonly query: Query,
readonly body: Body
) {
this.headers = request.headers;
this.path = request.path;
}
public getFilteredHeaders(headersToKeep: string[]) {
return filterHeaders(this.headers, headersToKeep);
}
// eslint-disable-next-line @typescript-eslint/camelcase
public unstable_getIncomingMessage() {
return this.request.raw.req;
}
}

View file

@ -171,4 +171,4 @@ export class Router {
export type RequestHandler<P extends ObjectType, Q extends ObjectType, B extends ObjectType> = (
req: KibanaRequest<TypeOf<P>, TypeOf<Q>, TypeOf<B>>,
createResponse: ResponseFactory
) => Promise<KibanaResponse<any>>;
) => KibanaResponse<any> | Promise<KibanaResponse<any>>;

View file

@ -58,6 +58,8 @@ export interface PluginSetupContext {
http: {
registerAuth: HttpServiceSetup['registerAuth'];
registerOnRequest: HttpServiceSetup['registerOnRequest'];
getBasePathFor: HttpServiceSetup['getBasePathFor'];
setBasePathFor: HttpServiceSetup['setBasePathFor'];
};
}
@ -148,6 +150,8 @@ export function createPluginSetupContext<TPlugin, TPluginDependencies>(
http: {
registerAuth: deps.http.registerAuth,
registerOnRequest: deps.http.registerOnRequest,
getBasePathFor: deps.http.getBasePathFor,
setBasePathFor: deps.http.setBasePathFor,
},
};
}

View file

@ -16,6 +16,7 @@ import { Server } from 'hapi';
import { ServerOptions } from 'hapi';
import { Type } from '@kbn/config-schema';
import { TypeOf } from '@kbn/config-schema';
import { Url } from 'url';
// @public (undocumented)
export type APICaller = (endpoint: string, clientParams: Record<string, unknown>, options?: CallAPIOptions) => Promise<unknown>;
@ -143,8 +144,8 @@ export interface HttpServiceStart {
}
// @public (undocumented)
export class KibanaRequest<Params, Query, Body> {
constructor(req: Request, params: Params, query: Query, body: Body);
export class KibanaRequest<Params = unknown, Query = unknown, Body = unknown> {
constructor(request: Request, params: Params, query: Query, body: Body);
// (undocumented)
readonly body: Body;
// Warning: (ae-forgotten-export) The symbol "RouteSchemas" needs to be exported by the entry point index.d.ts
@ -159,6 +160,8 @@ export class KibanaRequest<Params, Query, Body> {
readonly path: string;
// (undocumented)
readonly query: Query;
// (undocumented)
unstable_getIncomingMessage(): import("http").IncomingMessage;
}
// @public
@ -242,6 +245,7 @@ export interface OnRequestToolkit {
rejected: (error: Error, options?: {
statusCode?: number;
}) => OnRequestResult;
setUrl: (newUrl: string | Url) => void;
}
// @public
@ -286,6 +290,8 @@ export interface PluginSetupContext {
http: {
registerAuth: HttpServiceSetup['registerAuth'];
registerOnRequest: HttpServiceSetup['registerOnRequest'];
getBasePathFor: HttpServiceSetup['getBasePathFor'];
setBasePathFor: HttpServiceSetup['setBasePathFor'];
};
}

View file

@ -31,7 +31,7 @@ export default async function (kbnServer, server, config) {
kbnServer.server = new Hapi.Server(kbnServer.newPlatform.params.serverOptions);
server = kbnServer.server;
setupBasePathProvider(server, config);
setupBasePathProvider(kbnServer);
await registerHapiPlugins(server);

View file

@ -17,22 +17,14 @@
* under the License.
*/
export function setupBasePathProvider(server, config) {
server.decorate('request', 'setBasePath', function (basePath) {
export function setupBasePathProvider(kbnServer) {
kbnServer.server.decorate('request', 'setBasePath', function (basePath) {
const request = this;
if (request.app._basePath) {
throw new Error(`Request basePath was previously set. Setting multiple times is not supported.`);
}
request.app._basePath = basePath;
kbnServer.newPlatform.setup.core.http.setBasePathFor(request, basePath);
});
server.decorate('request', 'getBasePath', function () {
kbnServer.server.decorate('request', 'getBasePath', function () {
const request = this;
const serverBasePath = config.get('server.basePath');
const requestBasePath = request.app._basePath || '';
return `${serverBasePath}${requestBasePath}`;
return kbnServer.newPlatform.setup.core.http.getBasePathFor(request);
});
}