-
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
[HTTP] Versioned API router designs #151596
Conversation
A few additional points for discussion
If we want to give plugins the easiest access to the versioning toolkit, we should implement it such that the // ...really haven't given this much thought:
const versionedRouter = coreSetup.http.createVersionedRouter();
versionedRouter.post( ... );
Note: consumers can implement this in the current design by doing something like: const mySingleHandler = (version: '1' | '2', ctx, req, res) => { /* infer body type, switch/if-else and do stuff */ }
const versionedRoute = versionedRouter.post({ path: '/api/my-api/foo' });
versionedRoute
.addVersion({ version: '1', validate: { /* some validation */ } }, async (ctx, req, res) => mySingleHandler('1', ctx, req, res))
.addVersion({ version: '2', validate: { /* some other validation */ } }, async (ctx, req, res) => mySingleHandler('2', ctx, req, res)) ...but a dedicated API designed to do this may be simpler to use. In the current design we require a new handler for each version of a route. In certain cases it may be preferable for consumers to receive the version value via With a single handler things become a bit less ergonomic: consumers will need to switch/map this onto their appropriate handler and infer types of body, query and params.
Taking @rudolf's point about familiarity of APIs further, the current design exposes a |
* Side Public License, v 1. | ||
*/ | ||
|
||
import { schema } from '@kbn/config-schema'; |
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.
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.
@@ -0,0 +1,13 @@ | |||
# @kbn/core-version-http-server |
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
.
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.
left some nits with thoughts on how we could future-proof the API even more, but these are all compatible changes we can make in the future so it doesn't have to block the PR
* 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 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
* ```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 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.
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.
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 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!
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.
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.
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.
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 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.
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.
'/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.
packages/core/versioning/core-version-http-server/src/version_http_toolkit.ts
Outdated
Show resolved
Hide resolved
…http_toolkit.ts Co-authored-by: Ahmad Bamieh <[email protected]>
Can you confirm I understand correctly the mechanism
|
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.
I think I'm missing something subtle in allowing path params to change. I left some comments/questions to clarify.
.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 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.
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.
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
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.
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 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!
packages/core/versioning/core-version-http-server/src/version_http_toolkit.ts
Outdated
Show resolved
Hide resolved
Would it be possible get a code example of a change like this? If I understand correctly: the intention is to not release new versions (only patch versions) for non-breaking changes. [UPDATE] |
CC @sebelga (forgot to add a ping) |
.addVersion( | ||
{ | ||
version: '2', | ||
path: '/api/my-app/foo/{id?}/{name?}', // Update "fooId" => "id", this is not a breaking change! |
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.
Why is it not a breaking change? Our code won't be able to access params.fooId
, the value will now be under params.id
right?
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.
breaking changes refer to changes which break clients, we can always adapt our code to deal with "internal" breaking changes like this. So a client will still construct exactly the same query whether we map that path variable to id
or fooId
I'm still confused on what a route version maps to. Can you confirm that we won't declare them incrementally ("v1", "v2", "v3"...) but based on a release cadence (we might have "v1", "v7" route version but not the intermediary ones) ? |
A route version is introduced by a plugin team any time they think it's necessary to make a breaking change. For public APIs this cadence should be extremely slow, we can't remove APIs in < 18 months. But for internal APIs we should feel free to introduce a new version as often as it helps remove technical debt or support new features. But api versions don't ever correspond to stack release versions. |
@@ -21,26 +21,62 @@ const versionedRouter = vtk.createVersionedRouter({ router }); | |||
// @ts-ignore unused variable | |||
const versionedRoute = versionedRouter | |||
.post({ | |||
path: '/api/my-app/foo/{name?}', | |||
path: '/api/my-app/foo/{fooId?}/{name?}', // <= design mistake, {name?} should be a query parameter, but it has been released... |
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.
Aside: What would the path be if fooId
isn't defined? /api/my-app/foo/undefined/name
or /api/my-app/foo//name
or something else?
Decoupling |
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.
We could iterate on the design forever but we have to make a start somewhere. The design looks good enough to me to start sharing.
Great work!
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.
LGTM
Pinging @elastic/kibana-core (Team:Core) |
Thanks for the early review @sebelga @rudolf @TinaHeiligers . I am going to merge this as our first iteration, we can continue updating it in following PRs. |
💚 Build Succeeded
Metrics [docs]
History
To update your PR or re-run it, just comment with: cc @jloleysens |
Cool got it 👍 The piece of the puzzle that I was missing was that each browser client (per plugin) hardcodes their latest version when bundling. And that version is unique for each plugin. |
## Summary Implements the designs from #151596 * Move `packages/versioning/*` into `packages/core/http` to follow existing structure more closely * Implements the first iteration of the versioned router as a wrapper/layer around the existing router * Adds some integration tests * Future work needed! Once we have a the versioned spec we should implement it in this wrapper layer * Validation is a little bit tricky because of when the `CoreKibanaResponse` object is instantiated, the approach taken here is to replace body, params, query on the route-level's request object Closes #149286 --------- Co-authored-by: kibanamachine <[email protected]>
Summary
This PR contains the initial designs for our versioned router API. This contribution contains only types, any implementation will come in later PRs.
Previous PR #149943