mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[8.x] [Http] Gracefully onboarding internal routes to `VersionedRouter` (#194789) (#194902)
# Backport This will backport the following commits from `main` to `8.x`: - [[Http] Gracefully onboarding internal routes to `VersionedRouter` (#194789)](https://github.com/elastic/kibana/pull/194789) <!--- Backport version: 9.4.3 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sqren/backport) <!--BACKPORT [{"author":{"name":"Jean-Louis Leysens","email":"jeanlouis.leysens@elastic.co"},"sourceCommit":{"committedDate":"2024-10-04T06:47:36Z","message":"[Http] Gracefully onboarding internal routes to `VersionedRouter` (#194789)\n\n## Summary\r\n\r\nIf an internal route goes from unversioned -> versioned today this will\r\nsurface as a breaking change. This PR allows internal routes to\r\ngracefully onboard to the versioned router.\r\n\r\nThe fix is to default to version `1` of an internal route if no version\r\nis provided. In this way old clients can continue to interact with the\r\nnew servers as developers onboard to the versioned router and roll out\r\nv2+ of an internal route.\r\n\r\n## Notes\r\n\r\nWe could use `buildNr` to narrow down internal routes defaulting to v1\r\nto older clients only. IMO this would be more accurate, but also of\r\nmarginal value. My rationale is: by requiring explicit versions during\r\ndev time the risk of us shipping new builds that don't provide a version\r\nis effectively nullified. Additionally we already run this risk with our\r\npublic route default behaviour (we even require that public routes have\r\nexplicit version in dev to mitigate this same risk for existing public\r\nclients).\r\n\r\nIf reviewers feel otherwise I'm happy to revisit this!\r\n\r\n### Checklist\r\n\r\nDelete any items that are not applicable to this PR.\r\n\r\n- [x] [Unit or functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere updated or added to match the most common scenarios\r\n\r\n\r\n### Risk Matrix\r\n\r\n| Risk | Probability | Severity | Mitigation/Notes |\r\n\r\n|---------------------------|-------------|----------|-------------------------|\r\n| By defaulting internal routes to v1 it's possible that behaviour\r\nbecomes less predictable. | Low | Low | This is already true for public\r\nroutes and we are allowing a more limited/more predictable version of\r\nthat here. |","sha":"afecfd8889b042501d8de72b6eb6d8a1b98cb586","branchLabelMapping":{"^v9.0.0$":"main","^v8.16.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Feature:http","Team:Core","release_note:skip","v9.0.0","v8.16.0","backport:version"],"title":"[Http] Gracefully onboarding internal routes to `VersionedRouter`","number":194789,"url":"https://github.com/elastic/kibana/pull/194789","mergeCommit":{"message":"[Http] Gracefully onboarding internal routes to `VersionedRouter` (#194789)\n\n## Summary\r\n\r\nIf an internal route goes from unversioned -> versioned today this will\r\nsurface as a breaking change. This PR allows internal routes to\r\ngracefully onboard to the versioned router.\r\n\r\nThe fix is to default to version `1` of an internal route if no version\r\nis provided. In this way old clients can continue to interact with the\r\nnew servers as developers onboard to the versioned router and roll out\r\nv2+ of an internal route.\r\n\r\n## Notes\r\n\r\nWe could use `buildNr` to narrow down internal routes defaulting to v1\r\nto older clients only. IMO this would be more accurate, but also of\r\nmarginal value. My rationale is: by requiring explicit versions during\r\ndev time the risk of us shipping new builds that don't provide a version\r\nis effectively nullified. Additionally we already run this risk with our\r\npublic route default behaviour (we even require that public routes have\r\nexplicit version in dev to mitigate this same risk for existing public\r\nclients).\r\n\r\nIf reviewers feel otherwise I'm happy to revisit this!\r\n\r\n### Checklist\r\n\r\nDelete any items that are not applicable to this PR.\r\n\r\n- [x] [Unit or functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere updated or added to match the most common scenarios\r\n\r\n\r\n### Risk Matrix\r\n\r\n| Risk | Probability | Severity | Mitigation/Notes |\r\n\r\n|---------------------------|-------------|----------|-------------------------|\r\n| By defaulting internal routes to v1 it's possible that behaviour\r\nbecomes less predictable. | Low | Low | This is already true for public\r\nroutes and we are allowing a more limited/more predictable version of\r\nthat here. |","sha":"afecfd8889b042501d8de72b6eb6d8a1b98cb586"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.0.0","branchLabelMappingKey":"^v9.0.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/194789","number":194789,"mergeCommit":{"message":"[Http] Gracefully onboarding internal routes to `VersionedRouter` (#194789)\n\n## Summary\r\n\r\nIf an internal route goes from unversioned -> versioned today this will\r\nsurface as a breaking change. This PR allows internal routes to\r\ngracefully onboard to the versioned router.\r\n\r\nThe fix is to default to version `1` of an internal route if no version\r\nis provided. In this way old clients can continue to interact with the\r\nnew servers as developers onboard to the versioned router and roll out\r\nv2+ of an internal route.\r\n\r\n## Notes\r\n\r\nWe could use `buildNr` to narrow down internal routes defaulting to v1\r\nto older clients only. IMO this would be more accurate, but also of\r\nmarginal value. My rationale is: by requiring explicit versions during\r\ndev time the risk of us shipping new builds that don't provide a version\r\nis effectively nullified. Additionally we already run this risk with our\r\npublic route default behaviour (we even require that public routes have\r\nexplicit version in dev to mitigate this same risk for existing public\r\nclients).\r\n\r\nIf reviewers feel otherwise I'm happy to revisit this!\r\n\r\n### Checklist\r\n\r\nDelete any items that are not applicable to this PR.\r\n\r\n- [x] [Unit or functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere updated or added to match the most common scenarios\r\n\r\n\r\n### Risk Matrix\r\n\r\n| Risk | Probability | Severity | Mitigation/Notes |\r\n\r\n|---------------------------|-------------|----------|-------------------------|\r\n| By defaulting internal routes to v1 it's possible that behaviour\r\nbecomes less predictable. | Low | Low | This is already true for public\r\nroutes and we are allowing a more limited/more predictable version of\r\nthat here. |","sha":"afecfd8889b042501d8de72b6eb6d8a1b98cb586"}},{"branch":"8.x","label":"v8.16.0","branchLabelMappingKey":"^v8.16.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}] BACKPORT--> Co-authored-by: Jean-Louis Leysens <jeanlouis.leysens@elastic.co>
This commit is contained in:
parent
18b2b5c371
commit
bc434697c9
5 changed files with 84 additions and 31 deletions
|
@ -548,7 +548,7 @@ describe('Versioned route', () => {
|
|||
expect(
|
||||
// @ts-expect-error
|
||||
versionedRoute.getSecurity({
|
||||
headers: {},
|
||||
headers: { [ELASTIC_HTTP_VERSION_HEADER]: '99' },
|
||||
})
|
||||
).toStrictEqual(securityConfigDefault);
|
||||
|
||||
|
@ -569,7 +569,7 @@ describe('Versioned route', () => {
|
|||
expect(
|
||||
// @ts-expect-error
|
||||
versionedRoute.getSecurity({
|
||||
headers: {},
|
||||
headers: { [ELASTIC_HTTP_VERSION_HEADER]: '99' },
|
||||
})
|
||||
).toStrictEqual(securityConfigDefault);
|
||||
expect(router.get).toHaveBeenCalledTimes(1);
|
||||
|
|
|
@ -25,10 +25,10 @@ import type {
|
|||
RouteConfigOptions,
|
||||
RouteSecurityGetter,
|
||||
RouteSecurity,
|
||||
RouteMethod,
|
||||
} from '@kbn/core-http-server';
|
||||
import type { Mutable } from 'utility-types';
|
||||
import type { Method, VersionedRouterRoute } from './types';
|
||||
import type { CoreVersionedRouter } from './core_versioned_router';
|
||||
import type { HandlerResolutionStrategy, Method, VersionedRouterRoute } from './types';
|
||||
|
||||
import { validate } from './validate';
|
||||
import {
|
||||
|
@ -44,9 +44,16 @@ import { validRouteSecurity } from '../security_route_config_validator';
|
|||
import { resolvers } from './handler_resolvers';
|
||||
import { prepareVersionedRouteValidation, unwrapVersionedResponseBodyValidation } from './util';
|
||||
import type { RequestLike } from './route_version_utils';
|
||||
import { Router } from '../router';
|
||||
|
||||
type Options = AddVersionOpts<unknown, unknown, unknown>;
|
||||
|
||||
interface InternalVersionedRouteConfig<M extends RouteMethod> extends VersionedRouteConfig<M> {
|
||||
isDev: boolean;
|
||||
useVersionResolutionStrategyForInternalPaths: Map<string, boolean>;
|
||||
defaultHandlerResolutionStrategy: HandlerResolutionStrategy;
|
||||
}
|
||||
|
||||
// This validation is a pass-through so that we can apply our version-specific validation later
|
||||
export const passThroughValidation = {
|
||||
body: schema.nullable(schema.any()),
|
||||
|
@ -75,29 +82,43 @@ export class CoreVersionedRoute implements VersionedRoute {
|
|||
path,
|
||||
options,
|
||||
}: {
|
||||
router: CoreVersionedRouter;
|
||||
router: Router;
|
||||
method: Method;
|
||||
path: string;
|
||||
options: VersionedRouteConfig<Method>;
|
||||
options: InternalVersionedRouteConfig<Method>;
|
||||
}) {
|
||||
return new CoreVersionedRoute(router, method, path, options);
|
||||
}
|
||||
|
||||
public readonly options: VersionedRouteConfig<Method>;
|
||||
|
||||
private useDefaultStrategyForPath: boolean;
|
||||
private isPublic: boolean;
|
||||
private isDev: boolean;
|
||||
private enableQueryVersion: boolean;
|
||||
private defaultSecurityConfig: RouteSecurity | undefined;
|
||||
private defaultHandlerResolutionStrategy: HandlerResolutionStrategy;
|
||||
private constructor(
|
||||
private readonly router: CoreVersionedRouter,
|
||||
private readonly router: Router,
|
||||
public readonly method: Method,
|
||||
public readonly path: string,
|
||||
public readonly options: VersionedRouteConfig<Method>
|
||||
internalOptions: InternalVersionedRouteConfig<Method>
|
||||
) {
|
||||
this.useDefaultStrategyForPath = router.useVersionResolutionStrategyForInternalPaths.has(path);
|
||||
this.isPublic = this.options.access === 'public';
|
||||
this.enableQueryVersion = this.options.enableQueryVersion === true;
|
||||
this.defaultSecurityConfig = validRouteSecurity(this.options.security, this.options.options);
|
||||
this.router.router[this.method](
|
||||
const {
|
||||
isDev,
|
||||
useVersionResolutionStrategyForInternalPaths,
|
||||
defaultHandlerResolutionStrategy,
|
||||
...options
|
||||
} = internalOptions;
|
||||
this.isPublic = options.access === 'public';
|
||||
this.isDev = isDev;
|
||||
this.defaultHandlerResolutionStrategy = defaultHandlerResolutionStrategy;
|
||||
this.useDefaultStrategyForPath =
|
||||
this.isPublic || useVersionResolutionStrategyForInternalPaths.has(path);
|
||||
this.enableQueryVersion = options.enableQueryVersion === true;
|
||||
this.defaultSecurityConfig = validRouteSecurity(options.security, options.options);
|
||||
this.options = options;
|
||||
this.router[this.method](
|
||||
{
|
||||
path: this.path,
|
||||
validate: passThroughValidation,
|
||||
|
@ -119,7 +140,7 @@ export class CoreVersionedRoute implements VersionedRoute {
|
|||
|
||||
/** This method assumes that one or more versions handlers are registered */
|
||||
private getDefaultVersion(): undefined | ApiVersion {
|
||||
return resolvers[this.router.defaultHandlerResolutionStrategy](
|
||||
return resolvers[this.defaultHandlerResolutionStrategy](
|
||||
[...this.handlers.keys()],
|
||||
this.options.access
|
||||
);
|
||||
|
@ -132,8 +153,14 @@ export class CoreVersionedRoute implements VersionedRoute {
|
|||
private getVersion(req: RequestLike): ApiVersion | undefined {
|
||||
let version;
|
||||
const maybeVersion = readVersion(req, this.enableQueryVersion);
|
||||
if (!maybeVersion && (this.isPublic || this.useDefaultStrategyForPath)) {
|
||||
version = this.getDefaultVersion();
|
||||
if (!maybeVersion) {
|
||||
if (this.useDefaultStrategyForPath) {
|
||||
version = this.getDefaultVersion();
|
||||
} else if (!this.isDev && !this.isPublic) {
|
||||
// When in production, we default internal routes to v1 to allow
|
||||
// gracefully onboarding of un-versioned to versioned routes
|
||||
version = '1';
|
||||
}
|
||||
} else {
|
||||
version = maybeVersion;
|
||||
}
|
||||
|
@ -207,7 +234,7 @@ export class CoreVersionedRoute implements VersionedRoute {
|
|||
|
||||
const response = await handler.fn(ctx, req, res);
|
||||
|
||||
if (this.router.isDev && validation?.response?.[response.status]?.body) {
|
||||
if (this.isDev && validation?.response?.[response.status]?.body) {
|
||||
const { [response.status]: responseValidation, unsafe } = validation.response;
|
||||
try {
|
||||
validate(
|
||||
|
@ -236,7 +263,7 @@ export class CoreVersionedRoute implements VersionedRoute {
|
|||
private validateVersion(version: string) {
|
||||
// We do an additional check here while we only have a single allowed public version
|
||||
// for all public Kibana HTTP APIs
|
||||
if (this.router.isDev && this.isPublic) {
|
||||
if (this.isDev && this.isPublic) {
|
||||
const message = isAllowedPublicVersion(version);
|
||||
if (message) {
|
||||
throw new Error(message);
|
||||
|
|
|
@ -74,10 +74,16 @@ export class CoreVersionedRouter implements VersionedRouter {
|
|||
(routeMethod: Method) =>
|
||||
(options: VersionedRouteConfig<Method>): VersionedRoute<Method, any> => {
|
||||
const route = CoreVersionedRoute.from({
|
||||
router: this,
|
||||
router: this.router,
|
||||
method: routeMethod,
|
||||
path: options.path,
|
||||
options,
|
||||
options: {
|
||||
...options,
|
||||
defaultHandlerResolutionStrategy: this.defaultHandlerResolutionStrategy,
|
||||
useVersionResolutionStrategyForInternalPaths:
|
||||
this.useVersionResolutionStrategyForInternalPaths,
|
||||
isDev: this.isDev,
|
||||
},
|
||||
});
|
||||
this.routes.add(route);
|
||||
return route;
|
||||
|
|
|
@ -293,31 +293,31 @@ describe('Routing versioned requests', () => {
|
|||
).resolves.toEqual('1');
|
||||
});
|
||||
|
||||
it('requires version headers to be set for internal HTTP APIs', async () => {
|
||||
it('defaults to v1 for internal HTTP APIs to allow gracefully onboarding internal routes to versioned router', async () => {
|
||||
await setupServer({ dev: false });
|
||||
|
||||
router.versioned
|
||||
.get({ path: '/my-path', access: 'internal' })
|
||||
.get({ path: '/my-internal-path', access: 'internal' })
|
||||
.addVersion(
|
||||
{ version: '1', validate: { response: { 200: { body: () => schema.number() } } } },
|
||||
async (ctx, req, res) => res.ok()
|
||||
async (ctx, req, res) => res.ok({ body: 'v1' })
|
||||
)
|
||||
.addVersion(
|
||||
{ version: '2', validate: { response: { 200: { body: () => schema.number() } } } },
|
||||
async (ctx, req, res) => res.ok()
|
||||
async (ctx, req, res) => res.ok({ body: 'v2' })
|
||||
);
|
||||
await server.start();
|
||||
|
||||
await expect(
|
||||
supertest
|
||||
.get('/my-path')
|
||||
.get('/my-internal-path')
|
||||
.unset('Elastic-Api-Version')
|
||||
.expect(400)
|
||||
.then(({ body }) => body.message)
|
||||
).resolves.toMatch(/Please specify.+version/);
|
||||
.expect(200)
|
||||
.then(({ text }) => text)
|
||||
).resolves.toMatch('v1');
|
||||
});
|
||||
|
||||
it('requires version headers to be set for public endpoints when in dev', async () => {
|
||||
it('requires version headers to be set for internal and public endpoints when in dev', async () => {
|
||||
await setupServer({ dev: true });
|
||||
router.versioned
|
||||
.get({
|
||||
|
@ -325,6 +325,14 @@ describe('Routing versioned requests', () => {
|
|||
access: 'public',
|
||||
})
|
||||
.addVersion({ version: '2023-10-31', validate: false }, async (ctx, req, res) => res.ok());
|
||||
|
||||
router.versioned
|
||||
.get({
|
||||
path: '/my-internal-path',
|
||||
access: 'internal',
|
||||
})
|
||||
.addVersion({ version: '1', validate: false }, async (ctx, req, res) => res.ok());
|
||||
|
||||
await server.start();
|
||||
|
||||
await expect(
|
||||
|
@ -334,6 +342,18 @@ describe('Routing versioned requests', () => {
|
|||
.expect(400)
|
||||
.then(({ body }) => body.message)
|
||||
).resolves.toMatch(/Please specify.+version/);
|
||||
|
||||
await supertest.get('/my-path').set('Elastic-Api-Version', '2023-10-31').expect(200);
|
||||
|
||||
await expect(
|
||||
supertest
|
||||
.get('/my-internal-path')
|
||||
.unset('Elastic-Api-Version')
|
||||
.expect(400)
|
||||
.then(({ body }) => body.message)
|
||||
).resolves.toMatch(/Please specify.+version/);
|
||||
|
||||
await supertest.get('/my-internal-path').set('Elastic-Api-Version', '1').expect(200);
|
||||
});
|
||||
|
||||
it('errors when no handler could be found', async () => {
|
||||
|
|
|
@ -122,13 +122,13 @@ export default function aggregateTests({ getService }: FtrProviderContext) {
|
|||
});
|
||||
|
||||
it(`${AGGREGATE_ROUTE} handles a bad request`, async () => {
|
||||
const response = await supertest.get(AGGREGATE_ROUTE).set('kbn-xsrf', 'foo').query({
|
||||
const response = await getRoute().query({
|
||||
query: 'asdf',
|
||||
groupBy: ORCHESTRATOR_NAMESPACE,
|
||||
page: 0,
|
||||
index: MOCK_INDEX,
|
||||
});
|
||||
expect(response.status).to.be(400);
|
||||
expect(response.status).to.be(500);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue