-
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 8 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,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'; | ||
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/{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 | ||
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.
Version 2's path could be: If someone still uses the "old" path with a name as the query param, and the route has changed only to accept an optional 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. 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.
Yes, that is a good point. I've updated the example to reflect this as a breaking change.
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 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. 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. 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.
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>; | ||
} |
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
.