-
Notifications
You must be signed in to change notification settings - Fork 8.3k
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
Changes from 5 commits
649c587
9d09f48
02f41b2
bd02291
e110070
5b80d9c
f70b66e
8b02ffd
313dd75
2c16429
c352f4c
fe9b46f
661d5f6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
# @kbn/core-version-http-server | ||
|
||
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. |
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 {}; |
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" | ||
} |
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'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 } }); | ||
* } | ||
* ); | ||
* ``` | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'll incoroporate your changes, but leave this comment open for others! There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. ( There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 |
||
*/ | ||
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> { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
/** 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>; | ||
} |
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/**/*", | ||
] | ||
} |
There was a problem hiding this comment.
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
.