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 5 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"
}
42 changes: 42 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,42 @@
/*
* 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',
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', 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,124 @@
/*
* 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,
RouteValidatorFullConfig,
RequestHandlerContextBase,
} from '@kbn/core-http-server';

type RqCtx = RequestHandlerContextBase;

/** A set of type literals to determine accepted versions */
export type Version = '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10';
jloleysens marked this conversation as resolved.
Show resolved Hide resolved

/** 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',
* 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', 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 } });
* }
* );
* ```
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: one of the cons of structuring an API by path instead of by version (our previous discussion) is that it becomes more awkward to make a breaking change to a path. So you have to create a new versionedRouter and it's no longer clear that it shares a "version history" with other paths/controllers.

So I wondered if we should allow a path to be specified in a version too. E.g. changing '/api/my-app/foo' -> '/api/my-app/foobar'

const versionedRoute = versionedRouter
.post({
  path: '/api/my-app/foo',
  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 and new path
.addVersion(
  { 
    version: '2',
    path: '/api/my-app/foobar', // <-- Version 2 makes a breaking change to the path
    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 } });
  }
);

This should not be a very common use case but it does happen.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's a great point. I think it could definitely happen with parameters added/removed/changed in the path!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll incoroporate your changes, but leave this comment open for others!

Copy link
Contributor

Choose a reason for hiding this comment

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

A "breaking change to a path" is basicaly a new path so not sure it is a breaking change for the client. ('/api/my-app/foobar' is a new "v1" route). The example of JL(in example.ts) where he changes the parameter name from "name" to "id" is maybe an exception.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point @sebelga , I realise that example did not capture the nature of the issue correctly. That would be a change that is not visible to consumers of the API. I'll update to capture it correctly.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The breaking change in the example is in the body where we change foo to fooName. I was just hoping to show that you can change name of params if you wanted to.

Copy link
Contributor

Choose a reason for hiding this comment

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

'/api/my-app/foobar' is a new "v1" route

It could be modeled that way but doesn't have to be. In e.g. alerting we changed our terminology so what was previously called alerts now became rules and actions became alerts. To capture this change in domain language they might want to release a new version of the API where many paths would be different. Having two completely different endpoints both use alerts with a different meaning would be confusing so here the new version helps users navigate the change.

*/
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>;
}

/**
* Configuraiton 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<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> {
Copy link
Contributor

Choose a reason for hiding this comment

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

this is probably not something that would often be used, but we could probably extend RouteConfigOptions to future proof the API so that e.g. changing the max body size or accepts content types doesn't require a new "parent" Router but can be done in a new version https://github.com/elastic/kibana/blob/main/packages/core/http/core-http-server/src/router/route.ts#L102

/** Version to assign to this route */
version: Version;
/** Validation for this version of a route */
validate: false | RouteValidatorFullConfig<P, Q, B>;
}

/** A versioned route */
export interface VersionedRoute<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<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