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

[HTTP] Versioned API router designs #151596

Merged
merged 13 commits into from
Feb 22, 2023
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ packages/core/usage-data/core-usage-data-base-server-internal @elastic/kibana-co
packages/core/usage-data/core-usage-data-server @elastic/kibana-core
packages/core/usage-data/core-usage-data-server-internal @elastic/kibana-core
packages/core/usage-data/core-usage-data-server-mocks @elastic/kibana-core
packages/core/versioning/core-version-http-server @elastic/kibana-core
x-pack/plugins/cross_cluster_replication @elastic/platform-deployment-management
packages/kbn-crypto @elastic/kibana-security
packages/kbn-crypto-browser @elastic/kibana-core
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,7 @@
"@kbn/core-usage-data-base-server-internal": "link:packages/core/usage-data/core-usage-data-base-server-internal",
"@kbn/core-usage-data-server": "link:packages/core/usage-data/core-usage-data-server",
"@kbn/core-usage-data-server-internal": "link:packages/core/usage-data/core-usage-data-server-internal",
"@kbn/core-version-http-server": "link:packages/core/versioning/core-version-http-server",
"@kbn/cross-cluster-replication-plugin": "link:x-pack/plugins/cross_cluster_replication",
"@kbn/crypto": "link:packages/kbn-crypto",
"@kbn/crypto-browser": "link:packages/kbn-crypto-browser",
Expand Down
13 changes: 13 additions & 0 deletions packages/core/versioning/core-version-http-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# @kbn/core-version-http-server
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Naming things, naming things... What to name things? Happy to change this. I chose to put this in a package outside core/http and follow the naming convention of -server. My guess is we will have the same thing later, but -browser.


This package contains types for sever-side HTTP versioning.

## Experimental

The types in this package are all experimental and may be subject to extensive changes.
Use this package as a reference for current thinking and as a starting point to
raise questions and discussion.

## Versioning specification

Currently the versioning spec is being designed.
10 changes: 10 additions & 0 deletions packages/core/versioning/core-version-http-server/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

// TODO: export once types are ready
export {};
13 changes: 13 additions & 0 deletions packages/core/versioning/core-version-http-server/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../../../..',
roots: ['<rootDir>/packages/core/versioning/core-version-http-server'],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/core-version-http-server",
"owner": "@elastic/kibana-core"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"name": "@kbn/core-version-http-server",
"private": true,
"version": "1.0.0",
"author": "Kibana Core",
"license": "SSPL-1.0 OR Elastic License 2.0"
}
46 changes: 46 additions & 0 deletions packages/core/versioning/core-version-http-server/src/example.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { schema } from '@kbn/config-schema';
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added this example as it is easier to follow than the doc comment version, we can remove this if we feel it is not helpful.

import type { IRouter, RequestHandlerContextBase } from '@kbn/core-http-server';
import type { VersionHTTPToolkit } from './version_http_toolkit';

interface MyCustomContext extends RequestHandlerContextBase {
fooService: { create: (value: string) => Promise<void> };
}
const vtk = {} as unknown as VersionHTTPToolkit;
const router = {} as unknown as IRouter<MyCustomContext>;

const versionedRouter = vtk.createVersionedRouter({ router });

// @ts-ignore unused variable
const versionedRoute = versionedRouter
.post({
path: '/api/my-app/foo/{name?}',
options: { timeout: { payload: 60000 } },
})
// First version of the API, accepts { foo: string } in the body
.addVersion(
{ version: '1', validate: { body: schema.object({ foo: schema.string() }) } },
async (ctx, req, res) => {
await ctx.fooService.create(req.body.foo);
return res.ok({ body: { foo: req.body.foo } });
}
)
// Second version of the API, accepts { fooName: string } in the body
.addVersion(
{
version: '2',
path: '/api/my-app/foo/{id?}', // Update the path to something new
Copy link
Contributor

Choose a reason for hiding this comment

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

path: '/api/my-app/foo/{id?}', // Update the path to something new
If routers also have to be backward compatible, then would it be better to accept both optional arguments? For example:

Version 2's path could be:
path: '/api/my-app/foo/{nameorid?}',
where nameorid = name | id | undefined

If someone still uses the "old" path with a name as the query param, and the route has changed only to accept an optional id, would we not consider that a breaking change and, therefore, not permitted?

My understanding is that we want public HTTP API's to be as stable as possible and any iterations on the route declaration should still work with older versions. Maybe I'm missing some subtlety here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If someone still uses the "old" path with a name as the query param, and the route has changed only to accept an optional id, would we not consider that a breaking change and, therefore, not permitted?

Yes, that is a good point. I've updated the example to reflect this as a breaking change.

My understanding is that we want public HTTP API's to be as stable as possible and any iterations on the route declaration should still work with older versions. Maybe I'm missing some subtlety here.

Also a good point :). My reasoning was that we don't want breaking changes such that a later version removes an earlier version. But I think there are nuanced cases of breaking changes that we should consider allowing if they bring code/functionality to a place that is more maintainable.

CC @rudolf

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe the example I shared in #151596 (comment) is helpful.

We want any given version of the API to be stable, but we don't want to hold back teams. If they deem it necessary / beneficial to make a breaking change they should create a new version for that so that they're unblocked.

Copy link
Contributor

Choose a reason for hiding this comment

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

make a breaking change they should create a new version

Creating a new version for a new path makes me far more comfortable!

validate: { body: schema.object({ fooName: schema.string() }) },
},
async (ctx, req, res) => {
await ctx.fooService.create(req.body.fooName);
return res.ok({ body: { fooName: req.body.fooName } });
}
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import type {
IRouter,
RouteConfig,
RouteMethod,
RequestHandler,
RouteConfigOptions,
RouteValidatorFullConfig,
RequestHandlerContextBase,
} from '@kbn/core-http-server';

type RqCtx = RequestHandlerContextBase;

/** Assuming that version will be a monotonically increasing number where: version > 0. */
export type Version = `${number}`;

/** Arguments to create a {@link VersionedRouter | versioned router}. */
export interface CreateVersionedRouterArgs<Ctx extends RqCtx = RqCtx> {
/** A router instance */
router: IRouter<Ctx>;
}

/**
* This interface is the starting point for creating versioned routers and routes
*
* @example
* const versionedRouter = vtk.createVersionedRouter({ router });
*
* ```ts
* const versionedRoute = versionedRouter
* .post({
* path: '/api/my-app/foo/{name?}',
* options: { timeout: { payload: 60000 } },
* })
* // First version of the API, accepts { foo: string } in the body
* .addVersion(
* { version: '1', validate: { body: schema.object({ foo: schema.string() }) } },
* async (ctx, req, res) => {
* await ctx.fooService.create(req.body.foo);
* return res.ok({ body: { foo: req.body.foo } });
* }
* )
* // Second version of the API, accepts { fooName: string } in the body
* .addVersion(
* {
* version: '2',
* path: '/api/my-app/foo/{id?}', // Update the path to something new
* validate: { body: schema.object({ fooName: schema.string() }) },
* },
* async (ctx, req, res) => {
* await ctx.fooService.create(req.body.fooName);
* return res.ok({ body: { fooName: req.body.fooName } });
* }
* );
* ```
*/
export interface VersionHTTPToolkit {
/**
* Create a versioned router
* @param args - The arguments to create a versioned router
* @returns A versioned router
*/
createVersionedRouter<Ctx extends RqCtx = RqCtx>(
args: CreateVersionedRouterArgs<Ctx>
): VersionedRouter<Ctx>;
}

/**
* Configuration for a versioned route
*/
export type VersionedRouteConfig<Method extends RouteMethod> = Omit<
RouteConfig<unknown, unknown, unknown, Method>,
'validate'
>;

/**
* Create an {@link VersionedRoute | versioned route}.
*
* @param config - The route configuration
* @returns A versioned route
*/
export type VersionedRouteRegistrar<Method extends RouteMethod, Ctx extends RqCtx = RqCtx> = (
config: VersionedRouteConfig<Method>
) => VersionedRoute<Method, Ctx>;

/**
* A router, very similar to {@link IRouter} that will return an {@link VersionedRoute}
* instead.
*/
export interface VersionedRouter<Ctx extends RqCtx = RqCtx> {
get: VersionedRouteRegistrar<'get', Ctx>;
put: VersionedRouteRegistrar<'put', Ctx>;
post: VersionedRouteRegistrar<'post', Ctx>;
patch: VersionedRouteRegistrar<'patch', Ctx>;
delete: VersionedRouteRegistrar<'delete', Ctx>;
options: VersionedRouteRegistrar<'options', Ctx>;
}

/**
* Options for a versioned route. Probably needs a lot more options like sunsetting
* of an endpoint etc.
*/
export interface AddVersionOpts<P, Q, B, Method extends RouteMethod = RouteMethod>
extends RouteConfigOptions<Method> {
/** Version to assign to this route */
version: Version;
/** Validation for this version of a route */
validate: false | RouteValidatorFullConfig<P, Q, B>;
/**
* Override the path of of this "route". Useful to update, add or change existing path parameters.
* @note This option should preferably not introduce dramatic changes to the path as we may be
jloleysens marked this conversation as resolved.
Show resolved Hide resolved
* better of creating a new route entirely.
*/
path?: string;
}

/** A versioned route */
export interface VersionedRoute<
Method extends RouteMethod = RouteMethod,
Ctx extends RqCtx = RqCtx
> {
/**
* Add a new version of this route
* @param opts {@link AddVersionOpts | Options} for this version of a route
* @param handler The request handler for this version of a route
* @returns A versioned route, allows for fluent chaining of version declarations
*/
addVersion<P, Q, B>(
opts: AddVersionOpts<P, Q, B>,
handler: RequestHandler<P, Q, B, Ctx>
): VersionedRoute<Method, Ctx>;
}
20 changes: 20 additions & 0 deletions packages/core/versioning/core-version-http-server/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node"
]
},
"include": [
"**/*.ts"
],
"kbn_references": [
"@kbn/config-schema",
"@kbn/core-http-server",
],
"exclude": [
"target/**/*",
]
}
2 changes: 2 additions & 0 deletions tsconfig.base.json
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,8 @@
"@kbn/core-usage-data-server-internal/*": ["packages/core/usage-data/core-usage-data-server-internal/*"],
"@kbn/core-usage-data-server-mocks": ["packages/core/usage-data/core-usage-data-server-mocks"],
"@kbn/core-usage-data-server-mocks/*": ["packages/core/usage-data/core-usage-data-server-mocks/*"],
"@kbn/core-version-http-server": ["packages/core/versioning/core-version-http-server"],
"@kbn/core-version-http-server/*": ["packages/core/versioning/core-version-http-server/*"],
"@kbn/cross-cluster-replication-plugin": ["x-pack/plugins/cross_cluster_replication"],
"@kbn/cross-cluster-replication-plugin/*": ["x-pack/plugins/cross_cluster_replication/*"],
"@kbn/crypto": ["packages/kbn-crypto"],
Expand Down
4 changes: 4 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3833,6 +3833,10 @@
version "0.0.0"
uid ""

"@kbn/core-version-http-server@link:packages/core/versioning/core-version-http-server":
version "0.0.0"
uid ""

"@kbn/core@link:src/core":
version "0.0.0"
uid ""
Expand Down